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
```
Ś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,
+35 -9
View File
@@ -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/<seria>/<karta>
~/dev/workspace/rv/meta/workspace-info
~/dev/workspace/rv/tools/rv-launcher
~/dev/workspace/rv/tools/rv32i-hazard3-env
~/dev/workspace/rv/series/<seria>/<karta>
```
`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<TAB>...
original_root<TAB>...
original_series_root<TAB>...
workspace_root<TAB>...
workspace_info_path<TAB>...
workspace_info_url<TAB>...
workspace_info_status<TAB>...
series_root<TAB>...
socket_root<TAB>...
token_path<TAB>...
@@ -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:
+12 -8
View File
@@ -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`
+36 -1
View File
@@ -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
```
+354 -90
View File
@@ -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
+2
View File
@@ -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",