Read series from workspace manifest

This commit is contained in:
mpabi
2026-04-29 20:42:30 +02:00
parent cb1fc4d450
commit 170d8435aa
6 changed files with 475 additions and 116 deletions
+36 -8
View File
@@ -26,7 +26,9 @@ python3 rvctl.py "$@"
./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 ## Co robi narzędzie
@@ -40,9 +42,10 @@ python3 rvctl.py "$@"
2. Listowanie i pobieranie kart pracy 2. Listowanie i pobieranie kart pracy
`rvctl` czyta serie, karty i zadania z workspace, pomaga wybrać materiał do `rvctl` czyta serie i karty z `meta/workspace-info`, zadania z pobranych
pracy oraz przygotowuje remotes potrzebne do repo odpowiedzi. Docelowy model kart, pomaga wybrać materiał do pracy oraz przygotowuje remotes potrzebne do
namespace `series`, `cards` i `tasks` jest w `doc/series.md`. repo odpowiedzi. Docelowy model namespace `series`, `cards` i `tasks` jest w
`doc/series.md`.
3. Uruchamianie środowiska programistycznego w kontenerach 3. Uruchamianie środowiska programistycznego w kontenerach
@@ -108,6 +111,16 @@ Po tym kroku w workspace jest już repo launchera:
└── rv-launcher └── 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: Etap 3: wczytaj token z remota do lokalnego store:
```bash ```bash
@@ -283,6 +296,18 @@ Po konfiguracji tokenów najpierw sprawdź stan store:
./rvctl tokens compare ./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 Następnie wylistuj dostępne serie. Jeżeli katalog `series` w workspace jeszcze
nie istnieje, `rvctl` utworzy go automatycznie: nie istnieje, `rvctl` utworzy go automatycznie:
@@ -293,6 +318,7 @@ nie istnieje, `rvctl` utworzy go automatycznie:
Przykładowy wynik: Przykładowy wynik:
```text ```text
fiz 3 0
inf 3 0 inf 3 0
``` ```
@@ -319,6 +345,8 @@ Karty trafiają do `series` w workspace:
```text ```text
~/dev/workspace/rv ~/dev/workspace/rv
├── meta
│ └── workspace-info
├── tools ├── tools
│ └── rv-launcher │ └── rv-launcher
├── tokens ├── tokens
@@ -351,10 +379,10 @@ karcie z `workspace.json`, czyli tutaj na `inf bss`:
Przykładowy wynik: Przykładowy wynik:
```text ```text
inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.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.s 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.s 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.s 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, Przełącz się na wybrane zadanie. `rvctl` bierze login ucznia ze store tokenów,
+35 -9
View File
@@ -22,21 +22,24 @@ Typowy układ:
~/dev/edu/repos/rv/rv-launcher ~/dev/edu/repos/rv/rv-launcher
~/dev/edu/repos/rv/rv32i-hazard3-env ~/dev/edu/repos/rv/rv32i-hazard3-env
~/dev/edu/repos/rv/series/<seria>/<karta> ~/dev/edu/repos/rv/series/<seria>/<karta>
~/dev/workspace/rv/meta/workspace-info
~/dev/workspace/rv/tools/rv-launcher ~/dev/workspace/rv/tools/rv-launcher
~/dev/workspace/rv/tools/rv32i-hazard3-env ~/dev/workspace/rv/tools/rv32i-hazard3-env
~/dev/workspace/rv/series/<seria>/<karta> ~/dev/workspace/rv/series/<seria>/<karta>
``` ```
`rvctl` czyta karty z `series_root` w workspace. Tool repo `rvctl` czyta publiczny opis serii i kart z `workspace-info`, a pobrane karty
`rv32i-hazard3-env` wybiera najpierw z workspace, a potem z fallbacku trzyma w `series_root` w workspace. Tool repo `rv32i-hazard3-env` wybiera
`~/dev/edu/repos/rv`, jeśli taki klon roboczy jeszcze nie istnieje. 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` - `workspace_info_path` wskazuje manifest, na przykład `~/dev/workspace/rv/meta/workspace-info/workspace.json`
- `series_root` wskazuje klony testowe w workspace, na przykład `~/dev/workspace/rv/series` - `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 ## Pobranie repo i przełączenie gałęzi
@@ -203,6 +206,8 @@ Szczegółowy help parsera:
Komendy: Komendy:
- `show-config` - `show-config`
- `workspace show`
- `workspace sync`
- `list-series` - `list-series`
- `list-cards [series]` - `list-cards [series]`
- `series list` - `series list`
@@ -238,6 +243,9 @@ config_path<TAB>...
original_root<TAB>... original_root<TAB>...
original_series_root<TAB>... original_series_root<TAB>...
workspace_root<TAB>... workspace_root<TAB>...
workspace_info_path<TAB>...
workspace_info_url<TAB>...
workspace_info_status<TAB>...
series_root<TAB>... series_root<TAB>...
socket_root<TAB>... socket_root<TAB>...
token_path<TAB>... token_path<TAB>...
@@ -259,10 +267,28 @@ Przykład:
./rvctl show-config ./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` ## `series list`
Listuje serie dostępne w źródłach i workspace. Komenda tworzy `series_root`, Listuje serie dostępne w `workspace-info` i workspace. Komenda tworzy
jeżeli katalog jeszcze nie istnieje. `series_root`, jeżeli katalog jeszcze nie istnieje.
Format wyjścia: Format wyjścia:
+12 -8
View File
@@ -12,13 +12,15 @@ Komendy kart pracy dzielimy na trzy poziomy:
- `series cards tasks` - operacje na zadaniach w ramach karty - `series cards tasks` - operacje na zadaniach w ramach karty
`rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii `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 i kart pochodzi docelowo z publicznego manifestu
workspace. `~/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 ## Szybki przepływ
```bash ```bash
./rvctl tokens compare ./rvctl tokens compare
./rvctl workspace sync
./rvctl series list ./rvctl series list
./rvctl series cards list inf ./rvctl series cards list inf
./rvctl series cards fetch inf bss ./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` ### `series list`
Listuje dostępne serie. Jeżeli `~/dev/workspace/rv/series` nie istnieje, Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już
komenda tworzy ten katalog. pobranych do lokalnego workspace. Jeżeli `~/dev/workspace/rv/series` nie
istnieje, komenda tworzy ten katalog.
```bash ```bash
./rvctl series list ./rvctl series list
@@ -42,6 +45,7 @@ komenda tworzy ten katalog.
Typowy wynik: Typowy wynik:
```text ```text
fiz 3 0
inf 3 0 inf 3 0
``` ```
@@ -147,10 +151,10 @@ Skrót dla domyślnej karty:
Przykładowy wynik: Przykładowy wynik:
```text ```text
inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.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.s 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.s 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.s 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` ### `series cards tasks show SERIES CARD TASK`
+36 -1
View File
@@ -10,4 +10,39 @@ Publiczny entrypoint launchera:
``` ```
Implementacja CLI znajduje się w `rvctl.py`. Plik `workspace.json` przechowuje 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
```
+354 -90
View File
@@ -12,7 +12,7 @@ import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from urllib import error as urlerror from urllib import error as urlerror
from urllib import request as urlrequest from urllib import request as urlrequest
@@ -44,6 +44,9 @@ class WorkspaceConfig:
original_root: Path original_root: Path
original_series_root: Path original_series_root: Path
workspace_root: Path workspace_root: Path
workspace_info_path: Path
workspace_info_url: str
workspace_info: dict | None
series_root: Path series_root: Path
socket_root: Path socket_root: Path
token_path: Path token_path: Path
@@ -59,6 +62,21 @@ class TaskEntry:
path: Path 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 = [ SCOPE_FIELDS = [
("a", "activitypub"), ("a", "activitypub"),
("A", "admin"), ("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) 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) 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) 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") candidate_values = raw.get("tools_root_candidates")
if not candidate_values: 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]) tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0])
defaults = raw.get("defaults", {}) defaults = raw.get("defaults", {})
git_raw = raw.get("git", {}) 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( 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"), source_org=git_raw.get("source_org", "edu-inf"),
answer_org=git_raw.get("answer_org", "c2025-1a-inf"), answer_org=git_raw.get("answer_org", "c2025-1a-inf"),
source_remote=git_raw.get("source_remote", "r1"), 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"), origin_remote=git_raw.get("origin_remote", "origin"),
fallback_branch=git_raw.get("fallback_branch", "build"), 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( return WorkspaceConfig(
config_path=config_path, config_path=config_path,
original_root=original_root, original_root=original_root,
original_series_root=original_series_root, original_series_root=original_series_root,
workspace_root=workspace_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, series_root=series_root,
socket_root=socket_root, socket_root=socket_root,
token_path=token_path, 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]: def series_directories(config: WorkspaceConfig) -> list[Path]:
config.series_root.mkdir(parents=True, exist_ok=True) config.series_root.mkdir(parents=True, exist_ok=True)
return sorted(path for path in config.series_root.iterdir() if path.is_dir()) 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 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: 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) cards = source_card_directories(config, series_name)
if not cards: if not cards:
raise SystemExit(f"Missing source series: {config.original_series_root / series_name}") raise SystemExit(f"Missing source series: {config.original_series_root / series_name}")
@@ -220,10 +404,11 @@ def resolve_selector(
return series_value, card_value return series_value, card_value
if series_arg and not card_arg: 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 series_exists(config, series_arg):
if not default_card: series_default_card = default_card_for_series(config, series_arg)
if not series_default_card:
raise SystemExit("Missing default card in config.") 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(): if default_series and (config.series_root / default_series / series_arg).is_dir():
return default_series, series_arg return default_series, series_arg
if default_series: 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 card_path = config.series_root / series_name / card_name
if card_path.is_dir(): if card_path.is_dir():
return card_path return card_path
source_card_path = resolve_source_card_path(config, series_name, card_name) card_info = resolve_card_info(config, series_name, card_name)
resolved_path = workspace_card_path(config, series_name, source_card_path) resolved_path = workspace_card_path_for_info(config, card_info)
if resolved_path.is_dir(): if resolved_path.is_dir():
return resolved_path return resolved_path
raise SystemExit(f"Missing card path: {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: def print_series(config: WorkspaceConfig) -> None:
config.series_root.mkdir(parents=True, exist_ok=True) 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)} 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)} 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)): for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)):
source_path = source_by_name.get(series_name) manifest = manifest_by_name.get(series_name)
workspace_path = workspace_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 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 ''}") 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.") raise SystemExit("Missing series name and no default series is configured.")
config.series_root.mkdir(parents=True, exist_ok=True) 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_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 {} 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: if not source_cards and not workspace_cards:
raise SystemExit(f"Missing series: {series_name}") raise SystemExit(f"Missing series: {series_name}")
for repo_name in sorted(set(source_cards) | set(workspace_cards)): 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) workspace_path = workspace_cards.get(repo_name)
title = read_card_title(workspace_path or source_path) if (workspace_path or source_path) else "" title = read_card_title(workspace_path) if workspace_path else ""
label = card_label_from_repo_name(repo_name) 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" status = "workspace" if workspace_path else "source"
if title: if title:
print(f"{label}\t{repo_name}\t{status}\t{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" 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( def ensure_answer_token_from_source(
config: WorkspaceConfig, config: WorkspaceConfig,
source_remote: str, 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 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: 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_info = resolve_card_info(config, series_name, card_name)
card_path = workspace_card_path(config, series_name, source_card_path) card_path = workspace_card_path_for_info(config, card_info)
if not card_path.is_dir(): if not card_path.is_dir():
raise SystemExit(f"Missing workspace card: {card_path}. Run: ./rvctl series cards fetch {series_name} {card_name}") raise SystemExit(f"Missing workspace card: {card_path}. Run: ./rvctl series cards fetch {series_name} {card_name}")
return card_path return card_path
def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name: str, dry_run: bool = False) -> dict[str, str]: 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) card_info = resolve_card_info(config, series_name, card_name)
repo_name = source_card_path.name card_config = config_for_card(config, card_info)
target_path = workspace_card_path(config, series_name, source_card_path) repo_name = card_info.repo
source_url = source_url_for_card(config, series_name, source_card_path) target_path = workspace_card_path_for_info(config, card_info)
answer_url = answer_url_for_card(config, repo_name) source_url = source_url_for_card_info(config, card_info)
source_branch = git_current_branch(config, source_card_path) answer_url = answer_url_for_card(card_config, repo_name)
source_branch = card_info.branch
if not dry_run: if not dry_run:
target_path.parent.mkdir(parents=True, exist_ok=True) 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(): if not target_path.exists():
subprocess.run(["git", "clone", source_url, str(target_path)], check=True) subprocess.run(["git", "clone", source_url, str(target_path)], check=True)
cloned = True cloned = True
origin_url = git_capture(target_path, ["remote", "get-url", config.git.origin_remote]) 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", config.git.source_remote]) is None: if origin_url and git_capture(target_path, ["remote", "get-url", card_config.git.source_remote]) is None:
subprocess.run( 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, check=True,
) )
set_git_remote(target_path, config.git.source_remote, source_url) set_git_remote(target_path, card_config.git.source_remote, source_url)
subprocess.run(["git", "-C", str(target_path), "fetch", config.git.source_remote, source_branch], check=True) subprocess.run(["git", "-C", str(target_path), "fetch", card_config.git.source_remote, source_branch], check=True)
current_branch = git_current_branch(config, target_path) current_branch = git_current_branch(card_config, target_path)
if cloned or current_branch == source_branch: if cloned or current_branch == source_branch:
if current_branch != source_branch: if current_branch != source_branch:
local_branch_exists = subprocess.run( 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) subprocess.run(["git", "-C", str(target_path), "switch", source_branch], check=True)
else: else:
subprocess.run( 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, 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( token_status, source_token = ensure_answer_token_from_source(
config, card_config,
config.git.source_remote, card_config.git.source_remote,
config.git.answer_remote, card_config.git.answer_remote,
answer_url, answer_url,
dry_run=dry_run, 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("user", "")),
str(source_token.get("value", "")), 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 { return {
"series": series_name, "series": series_name,
"card": card_label_from_repo_name(repo_name), "card": card_info.card_id,
"repo": repo_name, "repo": repo_name,
"source": source_url, "source": source_url,
"workspace": str(target_path), "workspace": str(target_path),
"source_remote": config.git.source_remote, "source_remote": card_config.git.source_remote,
"source_branch": source_branch, "source_branch": source_branch,
"answer_remote": config.git.answer_remote, "answer_remote": card_config.git.answer_remote,
"answer_org": config.git.answer_org, "answer_org": card_config.git.answer_org,
"answer_repo": repo_name, "answer_repo": repo_name,
"answer_url": answer_url, "answer_url": answer_url,
"token": config.git.answer_remote, "token": card_config.git.answer_remote,
"token_status": token_status, "token_status": token_status,
"answer_remote_status": answer_remote_status, "answer_remote_status": answer_remote_status,
"status": "dry-run" if dry_run else "ready", "status": "dry-run" if dry_run else "ready",
@@ -2781,14 +2983,16 @@ def switch_card_task(
branch_override: str | None = None, branch_override: str | None = None,
dry_run: bool = False, dry_run: bool = False,
) -> dict[str, str]: ) -> 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) card_path = resolve_workspace_card_path(config, series_name, card_name)
task_name = resolve_submission_task(config, card_path, task_selector) 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) source_repo_name = repo_name_from_url(source_url)
answer_url = build_answer_url(config, source_repo_name) answer_url = build_answer_url(card_config, source_repo_name)
student_user = store_user_for_submission(config, source_url, answer_url, required=branch_override is None) 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) 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( branch_exists = subprocess.run(
["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name], ["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
@@ -2800,14 +3004,14 @@ def switch_card_task(
answer_remote_status = "dry-run" answer_remote_status = "dry-run"
if not dry_run: if not dry_run:
token_status, source_token = ensure_answer_token_from_source( token_status, source_token = ensure_answer_token_from_source(
config, card_config,
config.git.source_remote, card_config.git.source_remote,
config.git.answer_remote, card_config.git.answer_remote,
answer_url, answer_url,
) )
answer_remote_status = set_git_remote( answer_remote_status = set_git_remote(
card_path, card_path,
config.git.answer_remote, card_config.git.answer_remote,
credentialed_remote_url( credentialed_remote_url(
answer_url, answer_url,
str(source_token.get("user", "")), str(source_token.get("user", "")),
@@ -2825,19 +3029,19 @@ def switch_card_task(
else: else:
subprocess.run(["git", "-C", str(card_path), "switch", "-c", branch_name], check=True) subprocess.run(["git", "-C", str(card_path), "switch", "-c", branch_name], check=True)
status = "created" 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 { return {
"series": series_name, "series": series_name,
"card": card_label_from_repo_name(card_path.name), "card": card_info.card_id,
"task": task_name, "task": task_name,
"student_user": student_user, "student_user": student_user,
"branch": branch_name, "branch": branch_name,
"answer_remote": config.git.answer_remote, "answer_remote": card_config.git.answer_remote,
"answer_url": answer_url, "answer_url": answer_url,
"token_status": token_status, "token_status": token_status,
"answer_remote_status": answer_remote_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, "upstream_status": upstream_status,
"workspace": str(card_path), "workspace": str(card_path),
"status": status, "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) config.series_root.mkdir(parents=True, exist_ok=True)
source_path = config.original_series_root / series_name source_path = config.original_series_root / series_name
workspace_path = config.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(): if not cards and not workspace_path.is_dir():
raise SystemExit(f"Missing series: {series_name}") raise SystemExit(f"Missing series: {series_name}")
print(f"series\t{series_name}") print(f"series\t{series_name}")
print(f"source_path\t{source_path if source_path.is_dir() else ''}") 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"workspace_path\t{workspace_path}")
print(f"cards_count\t{len(cards)}") 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: 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) card_info = resolve_card_info(config, series_name, card_name)
target_path = workspace_card_path(config, series_name, source_card_path) card_config = config_for_card(config, card_info)
repo_name = source_card_path.name target_path = workspace_card_path_for_info(config, card_info)
repo_name = card_info.repo
print(f"series\t{series_name}") 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"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"workspace_path\t{target_path}")
print(f"source_remote\t{config.git.source_remote}") print(f"source_remote\t{card_config.git.source_remote}")
print(f"source_url\t{source_url_for_card(config, series_name, source_card_path)}") print(f"source_url\t{source_url_for_card_info(config, card_info)}")
print(f"answer_remote\t{config.git.answer_remote}") print(f"source_branch\t{card_info.branch}")
print(f"answer_url\t{answer_url_for_card(config, repo_name)}") 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(): if target_path.is_dir():
print(f"tasks\t{' '.join(task_names(target_path))}") 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) print_series_details(config, args.series)
return return
if args.series_command == "fetch": if args.series_command == "fetch":
for source_card_path in source_card_directories(config, args.series): for card_info in source_card_infos(config, args.series):
result = fetch_card_to_workspace(config, args.series, source_card_path.name, dry_run=args.dry_run) result = fetch_card_to_workspace(config, args.series, card_info.card_id, dry_run=args.dry_run)
print_key_values(result) print_key_values(result)
print() print()
return return
@@ -2963,63 +3170,65 @@ def run_tasks(config: WorkspaceConfig, args: argparse.Namespace) -> None:
def print_submission_plan(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) 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}" selector = f"{series_name}/{card_name}"
card_path = resolve_card_path(config, 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") 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) source_repo_name = repo_name_from_url(source_url)
answer_repo_name = build_answer_repo_name(source_repo_name, class_name, args.date) answer_repo_name = build_answer_repo_name(source_repo_name, class_name, args.date)
answer_url = build_answer_url(config, answer_repo_name) answer_url = build_answer_url(card_config, answer_repo_name)
source_branch = git_current_branch(config, card_path) source_branch = git_current_branch(card_config, card_path)
task_name = resolve_submission_task(config, card_path, args.task) task_name = resolve_submission_task(config, card_path, args.task)
student_user = ( student_user = (
slug_value(args.nick, "student user") slug_value(args.nick, "student user")
if args.nick 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) branch_name = branch_value(args.branch) if args.branch else submission_branch_name(student_user, task_name)
print(f"selector\t{selector}") print(f"selector\t{selector}")
print(f"card_path\t{card_path}") print(f"card_path\t{card_path}")
print(f"task\t{task_name}") print(f"task\t{task_name}")
print(f"source_repo\t{config.git.source_org}/{source_repo_name}") print(f"source_repo\t{card_config.git.source_org}/{source_repo_name}")
print(f"source_remote\t{config.git.source_remote}") print(f"source_remote\t{card_config.git.source_remote}")
print(f"source_url\t{source_url}") print(f"source_url\t{source_url}")
print(f"source_branch\t{source_branch}") print(f"source_branch\t{source_branch}")
print(f"answer_remote\t{config.git.answer_remote}") print(f"answer_remote\t{card_config.git.answer_remote}")
print(f"answer_repo\t{config.git.answer_org}/{answer_repo_name}") print(f"answer_repo\t{card_config.git.answer_org}/{answer_repo_name}")
print(f"answer_url\t{answer_url}") print(f"answer_url\t{answer_url}")
print(f"student_user\t{student_user}") print(f"student_user\t{student_user}")
print(f"student_branch\t{branch_name}") print(f"student_branch\t{branch_name}")
if args.apply: 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( token_status, source_token = ensure_answer_token_from_source(
config, card_config,
config.git.source_remote, card_config.git.source_remote,
config.git.answer_remote, card_config.git.answer_remote,
answer_url, answer_url,
) )
answer_status = set_git_remote( answer_status = set_git_remote(
card_path, card_path,
config.git.answer_remote, card_config.git.answer_remote,
credentialed_remote_url( credentialed_remote_url(
answer_url, answer_url,
str(source_token.get("user", "")), str(source_token.get("user", "")),
str(source_token.get("value", "")), str(source_token.get("value", "")),
), ),
) )
print(f"apply_{config.git.source_remote}\t{source_status}") print(f"apply_{card_config.git.source_remote}\t{source_status}")
print(f"apply_token_{config.git.answer_remote}\t{token_status}") print(f"apply_token_{card_config.git.answer_remote}\t{token_status}")
print(f"apply_{config.git.answer_remote}\t{answer_status}") print(f"apply_{card_config.git.answer_remote}\t{answer_status}")
print("commands") 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 {card_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))} remote add {card_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))} 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))} 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: 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}") 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: def print_config(config: WorkspaceConfig) -> None:
print(f"config_path\t{config.config_path}") print(f"config_path\t{config.config_path}")
print(f"original_root\t{config.original_root}") print(f"original_root\t{config.original_root}")
print(f"original_series_root\t{config.original_series_root}") print(f"original_series_root\t{config.original_series_root}")
print(f"workspace_root\t{config.workspace_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"series_root\t{config.series_root}")
print(f"socket_root\t{config.socket_root}") print(f"socket_root\t{config.socket_root}")
print(f"token_path\t{config.token_path}") print(f"token_path\t{config.token_path}")
@@ -3192,6 +3445,7 @@ def print_main_overview(config_path: Path) -> None:
["command", "arguments", "purpose"], ["command", "arguments", "purpose"],
[ [
["show-config", "", "show resolved paths and Git defaults"], ["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"], ["series", "list|cards|fetch|tasks", "work with series, cards and tasks"],
["tasks", "list|show|switch", "shortcut for tasks in the default card"], ["tasks", "list|show|switch", "shortcut for tasks in the default card"],
["list-series", "", "compat alias for series list"], ["list-series", "", "compat alias for series list"],
@@ -3207,11 +3461,12 @@ def print_main_overview(config_path: Path) -> None:
["step", "command", "result"], ["step", "command", "result"],
[ [
["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"], ["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"],
["2", "./rvctl series list", "choose a series"], ["2", "./rvctl workspace sync", "clone or update workspace-info"],
["3", "./rvctl series cards list inf", "choose a card"], ["3", "./rvctl series list", "choose a series"],
["4", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"], ["4", "./rvctl series cards list inf", "choose a card"],
["5", "./rvctl tasks list", "list tasks from the default card"], ["5", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"],
["6", "./rvctl tasks switch 4", "switch to the answer branch for task 4"], ["6", "./rvctl tasks list", "list tasks from the default card"],
["7", "./rvctl tasks switch 4", "switch to the answer branch for task 4"],
], ],
) )
print() 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 = subparsers.add_parser("list-cards", help="List cards in a series.")
cards_parser.add_argument("series", nargs="?", help="Series id, for example 'inf'.") 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_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.")
tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True) tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True)
@@ -3559,6 +3820,9 @@ def main() -> int:
if args.command == "list-cards": if args.command == "list-cards":
print_cards(config, args.series) print_cards(config, args.series)
return 0 return 0
if args.command == "workspace":
run_workspace(config, args)
return 0
if args.command == "tasks": if args.command == "tasks":
run_tasks(config, args) run_tasks(config, args)
return 0 return 0
+2
View File
@@ -3,6 +3,8 @@
"original_root": "~/dev/edu/repos/rv", "original_root": "~/dev/edu/repos/rv",
"original_series_root": "~/dev/edu/repos/rv/series", "original_series_root": "~/dev/edu/repos/rv/series",
"workspace_root": "~/dev/workspace/rv", "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", "series_root": "~/dev/workspace/rv/series",
"socket_root": "~/dev/workspace/rv/sockets", "socket_root": "~/dev/workspace/rv/sockets",
"token_file": "~/dev/workspace/rv/tokens/tokens.json", "token_file": "~/dev/workspace/rv/tokens/tokens.json",