diff --git a/README.md b/README.md index f0b9b23..63e1eba 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ To repo zawiera launcher do workspace `rv`: - `workspace.json` trzyma rooty workspace, socketow i toolingu - `workspace.py` listuje serie i karty z dysku - `workspace.py tmux-container ...` stawia sesje `tmux` z kontenerem w `pane 0` +- `workspace.py submission ...` wylicza repo odpowiedzi w `zsl-inf` i branch ucznia - `doc/usage.md` opisuje przelaczniki CLI i format listowania ## Przygotowanie katalogu @@ -41,14 +42,6 @@ git fetch r1 main git switch --track -c main r1/main ``` -Jesli po zajeciach uzytkownik bedzie chcial wyslac projekt na swoje konto -Gitea, lepiej dodac osobny remote `r2`, zamiast przepinac `r1`: - -```bash -git remote add r2 http://LOGIN:TOKEN@77.90.8.171:3001/LOGIN/rv-launcher.git -git push -u r2 main -``` - ### Droga 2: lokalny `tokens/gitea_tokens.json` To jest wariant alternatywny, wygodniejszy wtedy, gdy launcher ma sam wykonywac @@ -127,6 +120,55 @@ git fetch origin feat/x git switch --track -c feat/x origin/feat/x ``` +## Repo odpowiedzi kart pracy + +Model pracy kart jest taki: + +- `r1` wskazuje repo z materialem z `edu-inf` +- `a1` wskazuje wspolne repo odpowiedzi w `zsl-inf` +- kazdy uczen wysyla swoja prace na branch o nazwie swojego nicku + +Repo odpowiedzi nie zawiera nicku w nazwie. Launcher buduje je w formacie: + +```text +zsl-inf/-- +``` + +Przyklad: + +```text +zsl-inf/lab-rv32i-strlen-bss-data-stack-4i-2026-04-26 +``` + +Przyklad planu dla ucznia `u1`: + +```bash +python3 workspace.py submission inf 03 --class 4i --nick u1 +``` + +Przyklad konfiguracji remote'ow w repo karty: + +```bash +python3 workspace.py submission inf 03 --class 4i --nick u1 --apply +``` + +Launcher wtedy: + +- czyta URL zrodlowego repo z `origin` aktualnej karty i ustawia go jako `r1` +- bierze basename tego repo z `edu-inf` i z niego buduje nazwe repo odpowiedzi +- wylicza repo odpowiedzi w `zsl-inf` i ustawia je jako `a1` +- proponuje branch ucznia, na przyklad `u1` + +Typowy wynik to: + +```text +source_repo edu-inf/lab-rv32i-strlen-bss-data-stack +source_remote r1 +answer_remote a1 +answer_repo zsl-inf/lab-rv32i-strlen-bss-data-stack-4i-2026-04-26 +student_branch u1 +``` + ## Wariant alternatywny: `git clone` Jesli celem nie jest cwiczenie `remote add` i `fetch`, mozna tez uzyc: @@ -146,6 +188,7 @@ Przyklady: python3 workspace.py show-config python3 workspace.py list-series python3 workspace.py list-cards inf +python3 workspace.py submission inf 03 --class 4i --nick u1 python3 workspace.py tmux-container inf 03 --dry-run python3 workspace.py tmux-container inf 03 --session rv-inf03 --attach ``` diff --git a/doc/usage.md b/doc/usage.md index 537ffe1..71927d1 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -39,15 +39,7 @@ Przyklad: ```bash git remote add r1 http://u1:TOKEN@77.90.8.171:3001/edu-tools/rv-launcher.git git fetch r1 main -git switch main -git pull --ff-only r1 main -``` - -Jesli po zajeciach uzytkownik bedzie chcial wysylac projekty na swoje konto -Gitea, moze przepiac remote `r1` na wlasne repo: - -```bash -git remote set-url r1 http://LOGIN:TOKEN@77.90.8.171:3001/LOGIN/rv-launcher.git +git switch --track -c main r1/main ``` #### Droga 2: lokalny `tokens/gitea_tokens.json` @@ -103,7 +95,7 @@ Wariant przez `r1`: ```bash git fetch r1 main -git switch main +git switch --track -c main r1/main git pull --ff-only r1 main ``` @@ -137,6 +129,7 @@ Komendy: - `show-config` - `list-series` - `list-cards [series]` +- `submission [series] [card]` - `tmux-container [series] [card]` ## `show-config` @@ -151,6 +144,13 @@ workspace_root... series_root... socket_root... tools_root... +git_base_url... +git_source_org... +git_answer_org... +git_source_remote... +git_answer_remote... +git_origin_remote... +git_fallback_branch... tools_root_candidates ... ``` @@ -206,6 +206,72 @@ python3 workspace.py list-cards python3 workspace.py list-cards inf ``` +## `submission [series] [card]` + +Wylicza flow oddawania rozwiazan: + +- `r1` jako repo z materialem z `edu-inf` +- `a1` jako repo odpowiedzi w `zsl-inf` +- branch ucznia na podstawie jego nicku + +Nazwa repo odpowiedzi jest budowana z nazwy repo zrodlowego z `edu-inf`, klasy +i daty: + +```text +-- +``` + +Przyklad: + +```text +lab-rv32i-strlen-bss-data-stack-4i-2026-04-26 +``` + +Argumenty pozycyjne: + +- `series` + Id serii albo pelny selector, na przyklad `inf` albo `inf/03`. +- `card` + Numer karty, na przyklad `03`. + +Przelaczniki: + +- `--class NAME` + Id klasy, na przyklad `4i`. +- `--nick NAME` + Nick ucznia. Domyslnie z niego powstaje nazwa brancha. +- `--branch NAME` + Nadpisuje domyslna nazwe brancha. +- `--date YYYY-MM-DD` + Data zajec uzywana w nazwie repo odpowiedzi. Domyslnie dzisiejsza. +- `--source-url URL` + Nadpisuje URL repo zrodlowego. Bez tego launcher czyta `origin` z repo karty. +- `--apply` + Dodaje albo aktualizuje remote `r1` i `a1` w repo karty. + +Typowy format wyjscia: + +```text +selectorinf/03 +card_path... +source_repoedu-inf/lab-rv32i-strlen-bss-data-stack +source_remoter1 +source_urlhttp://77.90.8.171:3001/edu-inf/lab-rv32i-strlen-bss-data-stack.git +source_branchdeploy +answer_remotea1 +answer_repozsl-inf/lab-rv32i-strlen-bss-data-stack-4i-2026-04-26 +answer_urlhttp://77.90.8.171:3001/zsl-inf/lab-rv32i-strlen-bss-data-stack-4i-2026-04-26.git +student_branchu1 +``` + +Przyklady: + +```bash +python3 workspace.py submission inf 03 --class 4i --nick u1 +python3 workspace.py submission inf 03 --class 4i --nick u1 --apply +python3 workspace.py submission inf/03 --class 4i --nick u2 --date 2026-04-26 +``` + ## `tmux-container [series] [card]` Tworzy nowa sesje `tmux` i uruchamia kontener w `pane 0`. diff --git a/workspace.json b/workspace.json index c11a2e2..b644fc9 100644 --- a/workspace.json +++ b/workspace.json @@ -7,6 +7,15 @@ "~/dev/workspace/tools/rv32i-hazard3-env", "~/dev/workspace/rv/tools/rv32i-hazard3-env" ], + "git": { + "base_url": "http://77.90.8.171:3001", + "source_org": "edu-inf", + "answer_org": "zsl-inf", + "source_remote": "r1", + "answer_remote": "a1", + "origin_remote": "origin", + "fallback_branch": "main" + }, "defaults": { "series": "inf", "card": "03", diff --git a/workspace.py b/workspace.py index 68c6e4d..ebfc253 100755 --- a/workspace.py +++ b/workspace.py @@ -2,8 +2,10 @@ from __future__ import annotations import argparse +import datetime as dt import json import os +import re import shlex import subprocess import sys @@ -19,6 +21,17 @@ def expand_path(raw_path: str, base_dir: Path) -> Path: return path.resolve() +@dataclass +class GitConfig: + base_url: str + source_org: str + answer_org: str + source_remote: str + answer_remote: str + origin_remote: str + fallback_branch: str + + @dataclass class WorkspaceConfig: config_path: Path @@ -28,6 +41,7 @@ class WorkspaceConfig: tools_root_candidates: list[Path] tools_root: Path defaults: dict[str, str] + git: GitConfig def load_config(config_path: Path) -> WorkspaceConfig: @@ -45,6 +59,16 @@ 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", {}) + git = GitConfig( + base_url=git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/"), + source_org=git_raw.get("source_org", "edu-inf"), + answer_org=git_raw.get("answer_org", "zsl-inf"), + source_remote=git_raw.get("source_remote", "r1"), + answer_remote=git_raw.get("answer_remote", "a1"), + origin_remote=git_raw.get("origin_remote", "origin"), + fallback_branch=git_raw.get("fallback_branch", "main"), + ) return WorkspaceConfig( config_path=config_path, @@ -54,6 +78,7 @@ def load_config(config_path: Path) -> WorkspaceConfig: tools_root_candidates=tools_root_candidates, tools_root=tools_root, defaults=defaults, + git=git, ) @@ -138,6 +163,129 @@ def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None: print(f"{card_path.name}\t{card_path}") +def slug_value(raw_value: str, field_name: str) -> str: + slug = re.sub(r"[^a-z0-9._-]+", "-", raw_value.strip().lower()).strip("-") + if not slug: + raise SystemExit(f"Empty {field_name} after slug conversion: {raw_value!r}") + return slug + + +def git_capture(repo_path: Path, git_args: list[str]) -> str | None: + result = subprocess.run( + ["git", "-C", str(repo_path)] + git_args, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + + +def git_current_branch(config: WorkspaceConfig, repo_path: Path) -> str: + branch_name = git_capture(repo_path, ["branch", "--show-current"]) + if branch_name: + return branch_name + + head_ref = git_capture(repo_path, ["symbolic-ref", "--short", f"refs/remotes/{config.git.origin_remote}/HEAD"]) + if head_ref and "/" in head_ref: + return head_ref.split("/", 1)[1] + + return config.git.fallback_branch + + +def fallback_source_repo_name(series_name: str, card_name: str) -> str: + return "-".join([slug_value(series_name, "series"), slug_value(card_name, "card")]) + + +def fallback_source_url(config: WorkspaceConfig, series_name: str, card_name: str) -> str: + repo_name = fallback_source_repo_name(series_name, card_name) + return f"{config.git.base_url}/{config.git.source_org}/{repo_name}.git" + + +def discover_source_url(config: WorkspaceConfig, repo_path: Path, series_name: str, card_name: str) -> str: + source_url = git_capture(repo_path, ["remote", "get-url", config.git.origin_remote]) + if source_url: + return source_url + + source_url = git_capture(repo_path, ["remote", "get-url", config.git.source_remote]) + if source_url: + return source_url + + return fallback_source_url(config, series_name, card_name) + + +def repo_name_from_url(remote_url: str) -> str: + repo_name = remote_url.rstrip("/").rsplit("/", 1)[-1] + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] + return slug_value(repo_name, "source repo") + + +def build_answer_repo_name(source_repo_name: str, class_name: str, date_value: str) -> str: + return "-".join([source_repo_name, slug_value(class_name, "class"), date_value]) + + +def build_answer_url(config: WorkspaceConfig, answer_repo_name: str) -> str: + return f"{config.git.base_url}/{config.git.answer_org}/{answer_repo_name}.git" + + +def set_git_remote(repo_path: Path, remote_name: str, remote_url: str) -> str: + existing_url = git_capture(repo_path, ["remote", "get-url", remote_name]) + if existing_url == remote_url: + return "unchanged" + if existing_url: + subprocess.run( + ["git", "-C", str(repo_path), "remote", "set-url", remote_name, remote_url], + check=True, + ) + return "updated" + + subprocess.run( + ["git", "-C", str(repo_path), "remote", "add", remote_name, remote_url], + check=True, + ) + return "added" + + +def print_submission_plan(config: WorkspaceConfig, args: argparse.Namespace) -> None: + series_name, card_name = resolve_selector(config, args.series, args.card) + selector = f"{series_name}/{card_name}" + card_path = resolve_card_path(config, series_name, card_name) + class_name = slug_value(args.class_name, "class") + branch_name = slug_value(args.branch or args.nick, "branch") + source_url = args.source_url or discover_source_url(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) + + print(f"selector\t{selector}") + print(f"card_path\t{card_path}") + print(f"source_repo\t{config.git.source_org}/{source_repo_name}") + print(f"source_remote\t{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_url\t{answer_url}") + print(f"student_branch\t{branch_name}") + + if args.apply: + source_status = set_git_remote(card_path, config.git.source_remote, source_url) + answer_status = set_git_remote(card_path, config.git.answer_remote, answer_url) + print(f"apply_{config.git.source_remote}\t{source_status}") + print(f"apply_{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))} 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)}") + + def tmux_target_exists(session_name: str) -> bool: result = subprocess.run( ["tmux", "has-session", "-t", session_name], @@ -229,6 +377,13 @@ def print_config(config: WorkspaceConfig) -> None: print(f"series_root\t{config.series_root}") print(f"socket_root\t{config.socket_root}") print(f"tools_root\t{config.tools_root}") + print(f"git_base_url\t{config.git.base_url}") + print(f"git_source_org\t{config.git.source_org}") + print(f"git_answer_org\t{config.git.answer_org}") + print(f"git_source_remote\t{config.git.source_remote}") + print(f"git_answer_remote\t{config.git.answer_remote}") + print(f"git_origin_remote\t{config.git.origin_remote}") + print(f"git_fallback_branch\t{config.git.fallback_branch}") print("tools_root_candidates") for path in config.tools_root_candidates: print(f" {path}") @@ -263,6 +418,33 @@ def build_parser() -> argparse.ArgumentParser: tmux_parser.add_argument("--attach", action="store_true", help="Attach to the session after creation.") tmux_parser.add_argument("--dry-run", action="store_true", help="Print commands instead of running tmux.") + submission_parser = subparsers.add_parser( + "submission", + help="Print or apply source/answer git remotes for a selected card.", + ) + submission_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/03'.") + submission_parser.add_argument("card", nargs="?", help="Card id, for example '03'.") + submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id, for example '4i'.") + submission_parser.add_argument("--nick", required=True, help="Student nick used as the branch name.") + submission_parser.add_argument( + "--branch", + help="Override the submission branch name. Defaults to the value of --nick.", + ) + submission_parser.add_argument( + "--date", + default=dt.date.today().isoformat(), + help="Lesson date used in the answer repo name. Default: today in YYYY-MM-DD.", + ) + submission_parser.add_argument( + "--source-url", + help="Override the source repo URL. By default launcher reads the current origin from the card repo.", + ) + submission_parser.add_argument( + "--apply", + action="store_true", + help="Write remotes into the selected card repo instead of only printing the plan.", + ) + return parser @@ -283,6 +465,9 @@ def main() -> int: if args.command == "tmux-container": run_tmux_container(config, args) return 0 + if args.command == "submission": + print_submission_plan(config, args) + return 0 parser.error(f"Unsupported command: {args.command}") return 2