Files
stem-launcher/rvctl.py
T
2026-04-29 20:47:21 +02:00

3903 lines
152 KiB
Python
Executable File

#!/usr/bin/env python3
"""rvctl command-line implementation."""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import os
import re
import shlex
import subprocess
import sys
from dataclasses import dataclass, replace
from pathlib import Path
from urllib import error as urlerror
from urllib import request as urlrequest
from urllib.parse import quote, unquote, urlsplit, urlunsplit
def expand_path(raw_path: str, base_dir: Path) -> Path:
expanded = os.path.expandvars(os.path.expanduser(raw_path))
path = Path(expanded)
if not path.is_absolute():
path = base_dir / 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
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
tools_root_candidates: list[Path]
tools_root: Path
defaults: dict[str, str]
git: GitConfig
@dataclass(frozen=True)
class TaskEntry:
name: str
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"),
("i", "issue"),
("m", "misc"),
("n", "notification"),
("o", "organization"),
("p", "package"),
("r", "repository"),
("u", "user"),
]
SCOPE_CATEGORY_ALIASES = {
"organization": ["organization", "org"],
"repository": ["repository", "repo"],
}
ORG_PERMISSION_FIELDS = [
("o", "owner"),
("a", "admin"),
("w", "write"),
("r", "read"),
("c", "create"),
]
REPO_PERMISSION_FIELDS = [
("o", "owner"),
("a", "admin"),
("w", "write"),
("r", "read"),
]
def load_config(config_path: Path) -> WorkspaceConfig:
raw = json.loads(config_path.read_text(encoding="utf-8"))
base_dir = config_path.parent
original_root = expand_path(raw.get("original_root", "~/dev/edu/repos/rv"), base_dir)
workspace_root = expand_path(raw.get("workspace_root", "~/dev/workspace/rv"), base_dir)
original_series_root = expand_path(raw.get("original_series_root", str(original_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)
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:
candidate_values = [
raw.get("tools_root", str(workspace_root / "tools" / "rv32i-hazard3-env")),
str(original_root / "rv32i-hazard3-env"),
]
tools_root_candidates = [expand_path(value, base_dir) for value in candidate_values]
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=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"),
answer_remote=git_raw.get("answer_remote", "r1a"),
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,
tools_root_candidates=tools_root_candidates,
tools_root=tools_root,
defaults=defaults,
git=git,
)
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())
def card_directories(series_path: Path) -> list[Path]:
return sorted(path for path in series_path.iterdir() if path.is_dir())
def source_series_directories(config: WorkspaceConfig) -> list[Path]:
if not config.original_series_root.is_dir():
return []
return sorted(path for path in config.original_series_root.iterdir() if path.is_dir())
def source_card_directories(config: WorkspaceConfig, series_name: str) -> list[Path]:
source_series_path = config.original_series_root / series_name
if not source_series_path.is_dir():
return []
return sorted(path for path in source_series_path.iterdir() if path.is_dir())
def card_label_from_repo_name(repo_name: str) -> str:
if repo_name.startswith("lab-"):
repo_name = repo_name[4:]
marker = "-strlen-"
if marker in repo_name:
return repo_name.split(marker, 1)[1].split("-", 1)[0]
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}")
normalized = slug_value(card_name, "card")
matches = []
for card_path in cards:
repo_name = card_path.name
label = card_label_from_repo_name(repo_name)
candidates = {repo_name, label}
if normalized in candidates or any(normalized in candidate for candidate in candidates):
matches.append(card_path)
if not matches:
available = ", ".join(card_label_from_repo_name(path.name) for path in cards)
raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}")
if len(matches) > 1:
available = ", ".join(path.name for path in matches)
raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}")
return matches[0]
def read_card_title(card_path: Path) -> str:
readme_path = card_path / "README.md"
if not readme_path.is_file():
return ""
for line in readme_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip()
return ""
def resolve_selector(
config: WorkspaceConfig,
series_arg: str | None,
card_arg: str | None,
) -> tuple[str, str]:
default_series = config.defaults.get("series")
default_card = config.defaults.get("card")
if series_arg and "/" in series_arg:
if card_arg:
raise SystemExit("Pass either '<series> <card>' or '<series/card>', not both forms at once.")
series_value, card_value = series_arg.split("/", 1)
return series_value, card_value
if series_arg and not card_arg:
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, series_default_card
if default_series and (config.series_root / default_series / series_arg).is_dir():
return default_series, series_arg
if default_series:
try:
source_card_path = resolve_source_card_path(config, default_series, series_arg)
if workspace_card_path(config, default_series, source_card_path).is_dir():
return default_series, series_arg
except SystemExit:
pass
series_value = series_arg or default_series
card_value = card_arg or default_card
if not series_value or not card_value:
raise SystemExit("Missing series/card selector and no defaults are configured.")
return series_value, card_value
def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path:
card_path = config.series_root / series_name / card_name
if card_path.is_dir():
return 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}")
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(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_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 ''}")
def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None:
series_name = series_arg or config.defaults.get("series")
if not series_name:
raise SystemExit("Missing series name and no default series is configured.")
config.series_root.mkdir(parents=True, exist_ok=True)
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_card = source_cards.get(repo_name)
workspace_path = workspace_cards.get(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}")
else:
print(f"{label}\t{repo_name}\t{status}")
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 branch_value(raw_value: str, field_name: str = "branch") -> str:
branch_name = re.sub(r"[^A-Za-z0-9._-]+", "-", raw_value.strip()).strip("-")
if not branch_name:
raise SystemExit(f"Empty {field_name} after branch conversion: {raw_value!r}")
return branch_name
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_repo_root(repo_path: Path) -> Path | None:
repo_root = git_capture(repo_path, ["rev-parse", "--show-toplevel"])
if not repo_root:
return None
return Path(repo_root)
def default_port_for_scheme(scheme: str) -> int | None:
if scheme == "http":
return 80
if scheme == "https":
return 443
return None
def endpoint_from_split(split_url) -> tuple[str, int | None]:
host_name = split_url.hostname
if not host_name:
raise SystemExit(f"Missing hostname in URL: {split_url.geturl()}")
port_value = split_url.port or default_port_for_scheme(split_url.scheme)
default_port = default_port_for_scheme(split_url.scheme)
if port_value is not None and port_value != default_port:
endpoint = f"{split_url.scheme}://{host_name}:{port_value}"
else:
endpoint = f"{split_url.scheme}://{host_name}"
return endpoint, port_value
def server_type_from_split(config: WorkspaceConfig, split_url) -> str:
endpoint, _ = endpoint_from_split(split_url)
base_split = urlsplit(config.git.base_url)
base_endpoint, _ = endpoint_from_split(base_split)
if endpoint == base_endpoint:
return "gitea"
host_name = (split_url.hostname or "").lower()
if host_name == "github.com" or host_name.endswith(".github.com"):
return "github"
if "gitlab" in host_name:
return "gitlab"
if "gitea" in host_name:
return "gitea"
return "unknown"
def repo_parts_from_url(remote_url: str) -> tuple[str, str]:
split_url = urlsplit(remote_url)
repo_path = unquote(split_url.path).strip("/")
if repo_path.endswith(".git"):
repo_path = repo_path[:-4]
path_parts = [part for part in repo_path.split("/") if part]
if len(path_parts) < 2:
return "", ""
return path_parts[0], path_parts[1]
def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | None:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"}:
return None
if split_url.hostname is None:
return None
endpoint, port_value = endpoint_from_split(split_url)
org_name, repo_name = repo_parts_from_url(remote_url)
return {
"endpoint": endpoint,
"type": server_type_from_split(config, split_url),
"scheme": split_url.scheme,
"host": split_url.hostname,
"port": port_value,
"org": org_name,
"repo": repo_name,
}
def empty_token_store() -> dict:
return {"version": 3, "tokens": []}
def next_token_name(tokens: dict[str, str]) -> str:
index = 1
while f"t{index}" in tokens:
index += 1
return f"t{index}"
def server_record_from_info(server_info: dict) -> dict:
return {
"type": str(server_info.get("type", "unknown")),
"endpoint": str(server_info.get("endpoint", "")),
"scheme": str(server_info.get("scheme", "")),
"host": str(server_info.get("host", "")),
"port": server_info.get("port"),
}
def normalize_server_record(config: WorkspaceConfig, raw_server: dict, endpoint: str | None = None) -> dict | None:
if not isinstance(raw_server, dict):
return None
endpoint_value = str(raw_server.get("endpoint") or endpoint or "")
if not endpoint_value:
return None
split_endpoint = urlsplit(endpoint_value)
host_value = raw_server.get("host") or split_endpoint.hostname or ""
scheme_value = raw_server.get("scheme") or split_endpoint.scheme
port_value = raw_server.get("port")
if port_value is None and split_endpoint.port is not None:
port_value = split_endpoint.port
server_type = raw_server.get("type") or "unknown"
if server_type == "unknown" and scheme_value and host_value:
server_url = endpoint_value
server_type = server_type_from_split(config, urlsplit(server_url))
return {
"type": str(server_type),
"endpoint": endpoint_value,
"scheme": str(scheme_value),
"host": str(host_value),
"port": port_value,
}
def normalize_token_entry(raw_token_entry) -> str | dict | None:
if isinstance(raw_token_entry, str):
return raw_token_entry if raw_token_entry else None
if not isinstance(raw_token_entry, dict):
return None
token_value = raw_token_entry.get("value")
if not isinstance(token_value, str) or not token_value:
return None
token_entry = {"value": token_value}
expires_at = raw_token_entry.get("expires_at")
if isinstance(expires_at, str) and expires_at:
token_entry["expires_at"] = expires_at
for field_name in ["remote", "org", "repo"]:
field_value = raw_token_entry.get(field_name)
if isinstance(field_value, str) and field_value:
token_entry[field_name] = field_value
return token_entry
def token_entry_value(token_entry) -> str:
if isinstance(token_entry, str):
return token_entry
if isinstance(token_entry, dict) and isinstance(token_entry.get("value"), str):
return token_entry["value"]
return ""
def token_entry_expires_at(token_entry) -> str:
if isinstance(token_entry, dict) and isinstance(token_entry.get("expires_at"), str):
return token_entry["expires_at"]
return ""
def token_entry_field(token_entry, field_name: str) -> str:
if isinstance(token_entry, dict) and isinstance(token_entry.get(field_name), str):
return token_entry[field_name]
return ""
def token_value_exists(tokens: dict, token_value: str) -> bool:
return any(token_entry_value(existing_token) == token_value for existing_token in tokens.values())
def normalize_remote_record(raw_remote: dict) -> dict | None:
if not isinstance(raw_remote, dict):
return None
remote_name = raw_remote.get("name") or raw_remote.get("remote")
if not isinstance(remote_name, str) or not remote_name:
return None
remote_record = {"name": remote_name, "org": "", "repo": ""}
for field_name in ["org", "repo"]:
field_value = raw_remote.get(field_name)
if isinstance(field_value, str) and field_value:
remote_record[field_name] = field_value
org_perm = normalize_permission_map(raw_remote.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
if org_perm is not None:
remote_record["org_perm"] = org_perm
repo_perm = normalize_permission_map(raw_remote.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
if repo_perm is not None:
remote_record["repo_perm"] = repo_perm
return remote_record
def valid_mask(raw_value: str, allowed: str, width: int) -> bool:
return len(raw_value) == width and all(char in allowed for char in raw_value)
def permission_map_from_mask(
raw_value: str,
fields: list[tuple[str, str]],
allowed: str,
) -> dict[str, str] | None:
if not valid_mask(raw_value, allowed, len(fields)):
return None
return {key: value for (key, _), value in zip(fields, raw_value)}
def normalize_permission_map(
raw_value,
fields: list[tuple[str, str]],
allowed: str,
unknown: str = "?",
) -> dict[str, str] | None:
if isinstance(raw_value, str):
return permission_map_from_mask(raw_value, fields, allowed)
if not isinstance(raw_value, dict):
return None
normalized: dict[str, str] = {}
found = False
for key, long_name in fields:
field_value = raw_value.get(key, raw_value.get(long_name))
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
normalized[key] = field_value
found = True
else:
normalized[key] = unknown
return normalized if found else None
def permission_map_to_mask(
raw_value,
fields: list[tuple[str, str]],
allowed: str,
unknown: str = "?",
) -> str:
if isinstance(raw_value, str) and valid_mask(raw_value, allowed, len(fields)):
return raw_value
if not isinstance(raw_value, dict):
return ""
values = []
for key, long_name in fields:
field_value = raw_value.get(key, raw_value.get(long_name))
if isinstance(field_value, str) and len(field_value) == 1 and field_value in allowed:
values.append(field_value)
else:
values.append(unknown)
return "".join(values)
def normalize_flat_token(
config: WorkspaceConfig,
raw_token: dict,
remote_id: str,
token_value: str,
user_name: str,
org_name: str,
repo_name: str,
org_perm=None,
repo_perm=None,
) -> dict | None:
server_record = normalize_server_record(config, raw_token.get("server", {}))
if server_record is None:
return None
token_record = {
"id": remote_id,
"value": token_value,
"server": server_record,
"org": org_name,
"repo": repo_name,
}
if user_name:
token_record["user"] = user_name
for field_name in ["valid", "expires_at"]:
field_value = raw_token.get(field_name)
if isinstance(field_value, str) and field_value:
token_record[field_name] = field_value
scope_record = normalize_permission_map(raw_token.get("scope"), SCOPE_FIELDS, "rw?!-")
if scope_record is not None:
token_record["scope"] = scope_record
org_record = normalize_permission_map(org_perm if org_perm is not None else raw_token.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-")
if org_record is not None:
token_record["org_perm"] = org_record
repo_record = normalize_permission_map(repo_perm if repo_perm is not None else raw_token.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-")
if repo_record is not None:
token_record["repo_perm"] = repo_record
return token_record
def normalize_v3_tokens(config: WorkspaceConfig, raw_token: dict) -> list[dict]:
if not isinstance(raw_token, dict):
return []
remote_id = raw_token.get("id") or raw_token.get("remote")
legacy_token_id = raw_token.get("token_id") or raw_token.get("token_ref")
token_value = raw_token.get("value")
if token_value is None:
token_value = raw_token.get("token")
if not isinstance(token_value, str):
return []
user_name = raw_token.get("user")
if not isinstance(user_name, str):
user_name = ""
if isinstance(remote_id, str) and remote_id:
token_record = normalize_flat_token(
config,
raw_token,
remote_id,
token_value,
user_name,
str(raw_token.get("org", "")),
str(raw_token.get("repo", "")),
)
return [token_record] if token_record is not None else []
raw_remotes = raw_token.get("remotes", [])
if isinstance(raw_remotes, dict):
raw_remotes = [
{"name": remote_name, **remote_value}
for remote_name, remote_value in raw_remotes.items()
if isinstance(remote_value, dict)
]
if not isinstance(raw_remotes, list):
raw_remotes = []
normalized_records: list[dict] = []
if isinstance(raw_remotes, list):
for raw_remote in raw_remotes:
remote_record = normalize_remote_record(raw_remote)
if remote_record is None:
continue
legacy_user_name = user_name
if not legacy_user_name and isinstance(legacy_token_id, str):
legacy_user_name = legacy_token_id
token_record = normalize_flat_token(
config,
raw_token,
remote_record["name"],
token_value,
legacy_user_name,
remote_record.get("org", ""),
remote_record.get("repo", ""),
remote_record.get("org_perm"),
remote_record.get("repo_perm"),
)
if token_record is not None:
normalized_records.append(token_record)
if not normalized_records and isinstance(legacy_token_id, str) and legacy_token_id:
token_record = normalize_flat_token(
config,
raw_token,
legacy_token_id,
token_value,
user_name,
str(raw_token.get("org", "")),
str(raw_token.get("repo", "")),
)
if token_record is not None:
normalized_records.append(token_record)
return normalized_records
def append_token_record(token_data: dict, token_record: dict) -> None:
tokens = token_data.setdefault("tokens", [])
token_key = (
token_record.get("server", {}).get("endpoint", ""),
token_record.get("id", ""),
)
for existing_record in tokens:
existing_key = (
existing_record.get("server", {}).get("endpoint", ""),
existing_record.get("id", ""),
)
if existing_key == token_key:
existing_record.update(token_record)
return
tokens.append(token_record)
def parse_datetime(raw_value: str) -> dt.datetime | None:
if not raw_value:
return None
try:
normalized_value = raw_value.replace("Z", "+00:00")
return dt.datetime.fromisoformat(normalized_value)
except ValueError:
return None
def is_expired(raw_value: str) -> bool:
expires_at = parse_datetime(raw_value)
if expires_at is None:
return False
if expires_at.tzinfo is None:
now = dt.datetime.now()
else:
now = dt.datetime.now(expires_at.tzinfo)
return expires_at <= now
def valid_label(api_accepts_token: bool, expires_at: str) -> str:
if not api_accepts_token or (expires_at and is_expired(expires_at)):
return "invalid"
if expires_at:
return expires_at
return "forever"
def copy_legacy_users(token_data: dict, server_info: dict, raw_users: dict) -> dict:
for user_name, user_entry in raw_users.items():
if not isinstance(user_entry, dict):
continue
raw_tokens = user_entry.get("tokens", {})
if not isinstance(raw_tokens, dict):
continue
for token_name, raw_token_entry in raw_tokens.items():
token_entry = normalize_token_entry(raw_token_entry)
if token_entry is None:
continue
token_value = token_entry_value(token_entry)
remote_name = token_entry_field(token_entry, "remote") or str(token_name)
token_record = {
"id": remote_name,
"value": token_value,
"server": server_record_from_info(server_info),
"user": str(user_name),
"org": token_entry_field(token_entry, "org"),
"repo": token_entry_field(token_entry, "repo"),
}
expires_at = token_entry_expires_at(token_entry)
if expires_at:
token_record["expires_at"] = expires_at
append_token_record(token_data, token_record)
return token_data
def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict:
if raw_data.get("version") == 3 and isinstance(raw_data.get("tokens"), list):
token_data = empty_token_store()
for raw_token in raw_data["tokens"]:
for token_record in normalize_v3_tokens(config, raw_token):
append_token_record(token_data, token_record)
return token_data
if "servers" in raw_data and isinstance(raw_data["servers"], dict):
token_data = empty_token_store()
for endpoint, server_entry in raw_data["servers"].items():
if not isinstance(server_entry, dict):
continue
base_server_info = {
"endpoint": str(endpoint),
"type": server_entry.get("type", "unknown"),
"scheme": server_entry.get("scheme", ""),
"host": server_entry.get("host", ""),
"port": server_entry.get("port"),
}
copy_legacy_users(token_data, base_server_info, server_entry.get("users", {}))
return token_data
token_data = empty_token_store()
if "users" in raw_data and isinstance(raw_data["users"], dict):
base_server_info = server_info_from_url(config, config.git.base_url)
if base_server_info is None:
raise SystemExit(f"Invalid git base URL: {config.git.base_url}")
copy_legacy_users(token_data, base_server_info, raw_data["users"])
return token_data
def load_token_store(config: WorkspaceConfig, write_normalized: bool = True) -> dict:
if not config.token_path.exists():
return empty_token_store()
raw_data = json.loads(config.token_path.read_text(encoding="utf-8"))
if not isinstance(raw_data, dict):
raise SystemExit(f"Invalid token store format: {config.token_path}")
normalized_data = normalize_token_store(config, raw_data)
if write_normalized and raw_data != normalized_data:
write_token_store(config, normalized_data)
return normalized_data
def write_token_store(config: WorkspaceConfig, token_data: dict) -> None:
config.token_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(config.token_path.parent, 0o700)
temp_path = config.token_path.with_suffix(config.token_path.suffix + ".tmp")
temp_path.write_text(json.dumps(token_data, indent=2) + "\n", encoding="utf-8")
os.chmod(temp_path, 0o600)
temp_path.replace(config.token_path)
os.chmod(config.token_path, 0o600)
def token_server_endpoint(token_record: dict) -> str:
server_record = token_record.get("server", {})
if not isinstance(server_record, dict):
return ""
return str(server_record.get("endpoint", ""))
def token_record_id(token_record: dict) -> str:
return str(token_record.get("id", ""))
def find_token_record(token_data: dict, endpoint: str, remote_id: str) -> dict | None:
for token_record in token_data.get("tokens", []):
if token_server_endpoint(token_record) == endpoint and token_record_id(token_record) == remote_id:
return token_record
return None
def apply_authz_to_token(token_record: dict, authz: dict) -> bool:
changed = False
for source_name, target_name in [
("user", "user"),
("valid", "valid"),
]:
source_value = authz.get(source_name)
if isinstance(source_value, str) and source_value and token_record.get(target_name) != source_value:
token_record[target_name] = source_value
changed = True
scope_value = authz.get("scope_mask")
if isinstance(scope_value, str):
scope_record = permission_map_from_mask(scope_value, SCOPE_FIELDS, "rw?!-")
if scope_record is not None and token_record.get("scope") != scope_record:
token_record["scope"] = scope_record
changed = True
for source_name, target_name in [
("org_mask", "org_perm"),
("repo_mask", "repo_perm"),
]:
source_value = authz.get(source_name)
if not isinstance(source_value, str):
continue
fields = ORG_PERMISSION_FIELDS if target_name == "org_perm" else REPO_PERMISSION_FIELDS
permission_record = permission_map_from_mask(source_value, fields, "+?!-")
if permission_record is not None and token_record.get(target_name) != permission_record:
token_record[target_name] = permission_record
changed = True
return changed
def unknown_permission_map(fields: list[tuple[str, str]]) -> dict[str, str]:
return {short_name: "?" for short_name, _ in fields}
def reset_token_api_metadata(token_record: dict) -> bool:
changed = False
if token_record.get("valid") != "?":
token_record["valid"] = "?"
changed = True
if token_record.pop("expires_at", None) is not None:
changed = True
for field_name, fields in [
("scope", SCOPE_FIELDS),
("org_perm", ORG_PERMISSION_FIELDS),
("repo_perm", REPO_PERMISSION_FIELDS),
]:
unknown_record = unknown_permission_map(fields)
if token_record.get(field_name) != unknown_record:
token_record[field_name] = unknown_record
changed = True
return changed
def register_token(
token_data: dict,
server_info: dict,
remote_name: str,
user_name: str,
token_value: str,
hydrate: bool = True,
reset_api_metadata: bool = False,
) -> str:
endpoint = server_info["endpoint"]
token_record = find_token_record(token_data, endpoint, remote_name)
if token_record is None:
token_record = {
"id": remote_name,
"value": token_value,
"server": server_record_from_info(server_info),
"user": user_name,
"org": str(server_info.get("org", "")),
"repo": str(server_info.get("repo", "")),
}
token_data.setdefault("tokens", []).append(token_record)
result = "added"
else:
result = "existing"
if token_record.get("value") != token_value:
token_record["value"] = token_value
result = "updated"
if token_record.get("user") != user_name:
token_record["user"] = user_name
result = "updated"
if token_record.get("server") != server_record_from_info(server_info):
token_record["server"] = server_record_from_info(server_info)
result = "updated"
for source_name, target_name in [("org", "org"), ("repo", "repo")]:
source_value = server_info.get(source_name)
if isinstance(source_value, str) and token_record.get(target_name) != source_value:
token_record[target_name] = source_value
result = "updated"
if reset_api_metadata:
if reset_token_api_metadata(token_record) and result == "existing":
result = "updated"
if hydrate:
authz = load_gitea_authz(
endpoint,
remote_name,
token_value,
token_record.get("expires_at", ""),
token_record.get("org", ""),
token_record.get("repo", ""),
)
if apply_authz_to_token(token_record, authz) and result == "existing":
result = "updated"
return result
def remote_urls(repo_path: Path) -> list[str]:
return [remote_url for _, remote_url in remote_name_urls(repo_path)]
def remote_name_urls(repo_path: Path) -> list[tuple[str, str]]:
remote_output = git_capture(repo_path, ["remote"])
if not remote_output:
return []
urls: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for remote_name in remote_output.splitlines():
url_output = git_capture(repo_path, ["remote", "get-url", "--all", remote_name])
if not url_output:
continue
for remote_url in url_output.splitlines():
key = (remote_name, remote_url)
if key in seen:
continue
seen.add(key)
urls.append(key)
return urls
def remote_credentials(remote_url: str) -> tuple[str, str] | None:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"}:
return None
if split_url.username is None or split_url.password is None:
return None
return unquote(split_url.username), unquote(split_url.password)
def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict:
repo_root = git_repo_root(repo_path)
if repo_root is None:
return {
"repo_root": None,
"remotes": 0,
"urls": 0,
"scanned": 0,
"auth_urls": 0,
"plain_urls": 0,
"unsupported_urls": 0,
"found": 0,
"added": 0,
"updated": 0,
"servers": 0,
"remote_rows": [],
"found_credentials": [],
"changed": False,
}
found_credentials: list[tuple[int, dict, str, str, str]] = []
remote_entries = remote_name_urls(repo_root)
remote_names = {remote_name for remote_name, _ in remote_entries}
touched_servers: set[str] = set()
remote_rows: list[dict[str, str]] = []
auth_urls = 0
plain_urls = 0
unsupported_urls = 0
for remote_name, remote_url in remote_entries:
server_info = server_info_from_url(config, remote_url)
if server_info is None:
unsupported_urls += 1
remote_rows.append(
{
"remote": remote_name,
"endpoint": "",
"type": "unsupported",
"org": "",
"repo": "",
"url_kind": "unsupported",
"url": safe_remote_url_label(remote_url),
"token_id": "",
"token_value": "",
"result": "ignored",
}
)
continue
touched_servers.add(server_info["endpoint"])
credentials = remote_credentials(remote_url)
if credentials is None:
plain_urls += 1
remote_rows.append(
{
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "plain",
"url": safe_remote_url_label(remote_url),
"token_id": "",
"token_value": "",
"result": "no_credentials",
}
)
continue
auth_urls += 1
user_name, token_value = credentials
row_index = len(remote_rows)
remote_rows.append(
{
"remote": remote_name,
"endpoint": server_info["endpoint"],
"type": server_info["type"],
"org": server_info["org"],
"repo": server_info["repo"],
"url_kind": "auth",
"url": safe_remote_url_label(remote_url),
"token_id": remote_name,
"user": user_name,
"token_value": token_value,
"result": "found",
}
)
found_credentials.append((row_index, server_info, remote_name, user_name, token_value))
return {
"repo_root": repo_root,
"remotes": len(remote_names),
"urls": len(remote_entries),
"scanned": auth_urls + plain_urls,
"auth_urls": auth_urls,
"plain_urls": plain_urls,
"unsupported_urls": unsupported_urls,
"found": len(found_credentials),
"added": 0,
"updated": 0,
"servers": len(touched_servers),
"remote_rows": remote_rows,
"found_credentials": found_credentials,
"changed": False,
}
def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict:
report = scan_repo_remotes(config, repo_path)
found_credentials = report.get("found_credentials", [])
if not found_credentials:
return report
token_data = load_token_store(config)
added = 0
updated = 0
for row_index, server_info, remote_name, user_name, token_value in found_credentials:
result = register_token(token_data, server_info, remote_name, user_name, token_value)
if result == "added":
added += 1
elif result == "updated":
updated += 1
report["remote_rows"][row_index]["result"] = result
changed = added > 0 or updated > 0
if changed or not config.token_path.exists():
write_token_store(config, token_data)
report["added"] = added
report["updated"] = updated
report["changed"] = changed
return report
def sync_token_from_remote(config: WorkspaceConfig, repo_path: Path, remote_name: str, dry_run: bool = False) -> dict:
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
remote_url = git_capture(repo_root, ["remote", "get-url", remote_name])
if not remote_url:
raise SystemExit(f"Missing git remote: {remote_name}")
server_info = server_info_from_url(config, remote_url)
if server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}")
credentials = remote_credentials(remote_url)
if credentials is None:
raise SystemExit(f"Remote '{remote_name}' has no credentials.")
user_name, token_value = credentials
result = "dry-run"
if not dry_run:
token_data = load_token_store(config)
result = register_token(
token_data,
server_info,
remote_name,
user_name,
token_value,
hydrate=False,
reset_api_metadata=True,
)
write_token_store(config, token_data)
return {
"repo_root": str(repo_root),
"remote": remote_name,
"server": server_info["endpoint"],
"user": user_name,
"org": server_info.get("org", ""),
"repo": server_info.get("repo", ""),
"status": result,
}
def hydrate_token_from_api(config: WorkspaceConfig, remote_name: str, endpoint: str | None, dry_run: bool = False) -> dict:
token_data = load_token_store(config, write_normalized=not dry_run)
token_record = resolve_token_from_store(token_data, endpoint, remote_name, None)
token_value = str(token_record.get("value", ""))
if not token_value:
raise SystemExit(f"Token value is empty in tokens.json: {token_server_endpoint(token_record)}:{remote_name}")
authz = load_gitea_authz(
token_server_endpoint(token_record),
remote_name,
token_value,
token_record.get("expires_at", ""),
token_record.get("org", ""),
token_record.get("repo", ""),
)
changed = apply_authz_to_token(token_record, authz)
if changed and not dry_run:
write_token_store(config, token_data)
return {
"remote": remote_name,
"server": token_server_endpoint(token_record),
"user": str(token_record.get("user", "")),
"valid": str(token_record.get("valid", "")),
"status": "dry-run" if dry_run else ("updated" if changed else "unchanged"),
}
def count_server_tokens(server_entry: dict) -> int:
return len(server_entry.get("tokens", []))
def mask_secret(secret_value: str) -> str:
if len(secret_value) <= 4:
return "*" * len(secret_value)
return f"{secret_value[:2]}{'*' * (len(secret_value) - 4)}{secret_value[-2:]}"
def store_servers_from_tokens(token_data: dict) -> dict[str, dict]:
servers: dict[str, dict] = {}
for token_record in token_data.get("tokens", []):
server_record = token_record.get("server", {})
if not isinstance(server_record, dict):
continue
endpoint = str(server_record.get("endpoint", ""))
if not endpoint:
continue
server_entry = servers.setdefault(
endpoint,
{
"type": server_record.get("type", "unknown"),
"scheme": server_record.get("scheme", ""),
"host": server_record.get("host", ""),
"port": server_record.get("port"),
"tokens": [],
},
)
for field_name in ["type", "scheme", "host", "port"]:
if server_entry.get(field_name) in {"", None, "unknown"} and server_record.get(field_name) not in {"", None}:
server_entry[field_name] = server_record.get(field_name)
token_id = token_record_id(token_record)
if not token_id:
continue
server_entry["tokens"].append(
{
"endpoint": endpoint,
"token_id": token_id,
"token_value": str(token_record.get("value", "")),
"user": str(token_record.get("user", "")),
"valid": str(token_record.get("valid", "")),
"scope_mask": permission_map_to_mask(token_record.get("scope"), SCOPE_FIELDS, "rw?!-"),
"expires_at": str(token_record.get("expires_at", "")),
"remote": token_id,
"owner": str(token_record.get("org", "")),
"repo_name": str(token_record.get("repo", "")),
"org_mask": permission_map_to_mask(token_record.get("org_perm"), ORG_PERMISSION_FIELDS, "+?!-"),
"repo_mask": permission_map_to_mask(token_record.get("repo_perm"), REPO_PERMISSION_FIELDS, "+?!-"),
}
)
return servers
def token_store_servers(token_data: dict) -> list[tuple[str, dict]]:
return sorted(store_servers_from_tokens(token_data).items())
def token_pairs_from_entry(server_entry: dict) -> set[tuple[str, str, str, str, str]]:
token_pairs: set[tuple[str, str, str, str, str]] = set()
for row in server_entry.get("tokens", []):
token_id = row.get("token_id", "")
token_value = row.get("token_value", "")
if token_id and token_value:
token_pairs.add(
(
str(row.get("remote", "")),
str(row.get("user", "")),
str(token_value),
str(row.get("org", row.get("owner", ""))),
str(row.get("repo", row.get("repo_name", ""))),
)
)
return token_pairs
def count_users(users_data: dict) -> int:
return len(users_data)
def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tuple[Path | None, dict[str, dict]]:
repo_root = git_repo_root(repo_path)
if repo_root is None:
return None, {}
repo_servers: dict[str, dict] = {}
for remote_name, remote_url in remote_name_urls(repo_root):
server_info = server_info_from_url(config, remote_url)
if server_info is None:
continue
endpoint = server_info["endpoint"]
entry = repo_servers.setdefault(
endpoint,
{
"type": server_info["type"],
"scheme": server_info["scheme"],
"host": server_info["host"],
"port": server_info["port"],
"remotes": set(),
"urls": 0,
"auth_urls": 0,
"plain_urls": 0,
"tokens": [],
"plain_remotes": [],
},
)
entry["remotes"].add(remote_name)
entry["urls"] += 1
for field_name in ["type", "scheme", "host", "port"]:
if field_name not in entry or entry[field_name] in {"", None, "unknown"}:
entry[field_name] = server_info[field_name]
credentials = remote_credentials(remote_url)
if credentials is None:
entry["plain_urls"] += 1
entry["plain_remotes"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
}
)
continue
entry["auth_urls"] += 1
user_name, token_value = credentials
entry["tokens"].append(
{
"remote": remote_name,
"org": server_info["org"],
"repo": server_info["repo"],
"token_id": remote_name,
"user": user_name,
"token_value": token_value,
}
)
return repo_root, repo_servers
def compare_server_entries(repo_entry: dict | None, store_entry: dict | None) -> tuple[str, int, int]:
if repo_entry is None and store_entry is None:
return "missing", 0, 0
if repo_entry is not None and store_entry is None:
return "repo_only", count_server_tokens(repo_entry), 0
if repo_entry is None and store_entry is not None:
return "store_only", 0, count_server_tokens(store_entry)
repo_pairs = token_pairs_from_entry(repo_entry)
store_pairs = token_pairs_from_entry(store_entry)
repo_only_pairs = repo_pairs - store_pairs
store_only_pairs = store_pairs - repo_pairs
if not repo_only_pairs and not store_only_pairs:
return "in_sync", 0, 0
if repo_only_pairs and not store_only_pairs:
return "repo_ahead", len(repo_only_pairs), 0
if store_only_pairs and not repo_only_pairs:
return "store_ahead", 0, len(store_only_pairs)
return "diverged", len(repo_only_pairs), len(store_only_pairs)
def repo_result_for_entry(repo_entry: dict | None) -> tuple[str, str]:
if repo_entry is None:
return "missing", "missing"
if repo_entry.get("auth_urls", 0):
return "auth", "present"
if repo_entry.get("plain_urls", 0):
return "plain", "no_credentials"
return "empty", "no_credentials"
def print_token_context(
repo_root: Path | None,
config: WorkspaceConfig,
repo_servers: dict[str, dict],
store_servers: dict[str, dict],
endpoint_count: int,
) -> None:
print("context")
print("item\tvalue")
print(f"repo_root\t{repo_root or ''}")
print(f"token_path\t{config.token_path}")
print(f"repo_endpoints\t{len(repo_servers)}")
print(f"tokens_endpoints\t{len(store_servers)}")
print(f"tokens\t{endpoint_count}")
def print_status_counts(status_counts: dict[str, int]) -> None:
print("status")
print("item\tvalue")
for status_name in ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]:
print(f"{status_name}\t{status_counts[status_name]}")
TOKEN_REF_WIDTH = 9
TOKEN_COLUMNS = [
("item", "item", 4),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("remote", "remote", 6),
("token_ref", "token_ref", TOKEN_REF_WIDTH),
("token", "token", 12),
("valid", "valid", 19),
("scope_mask", "scope", 9),
("org_mask", "org", 6),
("repo_mask", "repo", 6),
]
TOKEN_LIST_COLUMNS = [
("item", "item", 4),
("source", "source", 6),
("kind", "kind", 5),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("remote", "remote", 6),
("token", "token", 12),
("valid", "valid", 19),
("scope_mask", "scope", 9),
("org_mask", "org", 6),
("repo_mask", "repo", 6),
]
TOKEN_SCAN_COLUMNS = [
("item", "item", 4),
("remote", "remote", 6),
("kind", "kind", 11),
("server", "server", 6),
("proto", "proto", 5),
("host", "host", 18),
("owner", "org", 9),
("repo_name", "repo", 11),
("user", "user", 4),
("token", "token", 12),
("result", "result", 14),
("url", "url", 38),
]
TOKEN_SEPARATOR_OVERRIDES = {
"scope_mask": "aAimnopru",
"org_mask": "oawrc-",
"repo_mask": "oawr--",
}
def fit_cell(value: str, width: int) -> str:
if len(value) <= width:
return value.ljust(width)
if width <= 3:
return value[:width]
return f"{value[:width - 3]}..."
def print_fixed_table(title: str, columns: list[tuple[str, str, int]], rows: list[dict[str, str]]) -> None:
print(title)
print(" ".join(fit_cell(label, width) for _, label, width in columns).rstrip())
print(" ".join(fit_cell(TOKEN_SEPARATOR_OVERRIDES.get(name, "-" * width), width) for name, _, width in columns).rstrip())
for row in rows:
print(" ".join(fit_cell(row.get(name, ""), width) for name, _, width in columns).rstrip())
def short_secret(secret_value: str) -> str:
if len(secret_value) <= 12:
return mask_secret(secret_value)
return f"{secret_value[:5]}...{secret_value[-4:]}"
def format_token_ref(token_name: str, marker: str) -> str:
name_width = TOKEN_REF_WIDTH - 2
return f"{fit_cell(token_name, name_width)}{marker:>2}"
def users_label(server_entry: dict | None) -> str:
if server_entry is None:
return ""
return ",".join(sorted(str(user_name) for user_name in server_entry.get("users", {})))
def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry: dict | None) -> dict[str, str]:
values = {
"server": "",
"scheme": "",
"host": "",
"port": "",
}
for server_entry in [repo_entry, store_entry]:
if server_entry is None:
continue
values["server"] = values["server"] or str(server_entry.get("type", ""))
values["scheme"] = values["scheme"] or str(server_entry.get("scheme", ""))
values["host"] = values["host"] or str(server_entry.get("host", ""))
port_value = server_entry.get("port", "")
values["port"] = values["port"] or ("" if port_value is None else str(port_value))
split_endpoint = urlsplit(endpoint)
values["scheme"] = values["scheme"] or split_endpoint.scheme
values["host"] = values["host"] or (split_endpoint.hostname or "")
if not values["port"] and split_endpoint.port is not None:
values["port"] = str(split_endpoint.port)
if values["host"] and values["port"]:
values["host"] = f"{values['host']}:{values['port']}"
return values
def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for endpoint, server_entry in sorted(store_servers.items()):
for row in server_entry.get("tokens", []):
token_value = row.get("token_value", "")
token_id = row.get("token_id", "")
if token_id:
rows.append(
{
"endpoint": endpoint,
"user": str(row.get("user", "")),
"token_id": str(token_id),
"remote": str(row.get("remote", "")),
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"token_value": str(token_value),
"expires_at": str(row.get("expires_at", "")),
"valid": str(row.get("valid", "")),
"scope_mask": str(row.get("scope_mask", "")),
"org_mask": str(row.get("org_mask", "")),
"repo_mask": str(row.get("repo_mask", "")),
}
)
return rows
def repo_token_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> list[dict[str, str]]:
if scan_rows is not None:
return [
{
"endpoint": row["endpoint"],
"remote": row["remote"],
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
for row in scan_rows
if row.get("endpoint") and row.get("url_kind") == "auth" and row.get("token_value")
]
rows: list[dict[str, str]] = []
for endpoint in endpoint_names:
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []):
rows.append(
{
"endpoint": endpoint,
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"token_id": row.get("token_id", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
)
return rows
def list_store_rows(store_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
endpoints = sorted(store_servers)
for endpoint in endpoints:
if server_filter and endpoint != server_filter:
continue
server_entry = store_servers[endpoint]
endpoint_values = endpoint_column_values(endpoint, None, server_entry)
for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")):
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"source": "store",
"kind": "auth" if token_value else "empty",
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"user": str(row.get("user", "")),
"remote": str(row.get("remote", "")),
"token": short_secret(token_value) if token_value else "",
"valid": str(row.get("valid", "")),
"scope_mask": str(row.get("scope_mask", "")),
"org_mask": str(row.get("org_mask", "")),
"repo_mask": str(row.get("repo_mask", "")),
}
)
return rows
def list_remote_rows(repo_servers: dict[str, dict], server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
endpoints = sorted(repo_servers)
for endpoint in endpoints:
if server_filter and endpoint != server_filter:
continue
server_entry = repo_servers[endpoint]
endpoint_values = endpoint_column_values(endpoint, server_entry, None)
remote_rows = []
for row in server_entry.get("tokens", []):
remote_rows.append(
{
"kind": "auth",
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": row.get("user", ""),
"token_value": row.get("token_value", ""),
}
)
for row in server_entry.get("plain_remotes", []):
remote_rows.append(
{
"kind": "plain",
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
"user": "",
"token_value": "",
}
)
for row in sorted(remote_rows, key=lambda item: (item.get("remote", ""), item.get("kind", ""))):
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"source": "remote",
"kind": str(row.get("kind", "")),
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": str(row.get("owner", "")),
"repo_name": str(row.get("repo_name", "")),
"user": str(row.get("user", "")),
"remote": str(row.get("remote", "")),
"token": short_secret(token_value) if token_value else "",
"valid": "?",
"scope_mask": "?????????",
"org_mask": "?????",
"repo_mask": "????",
}
)
return rows
def scan_remote_rows(report: dict, server_filter: str | None = None) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for row in report.get("remote_rows", []):
endpoint = str(row.get("endpoint", ""))
if server_filter and endpoint != server_filter:
continue
endpoint_values = endpoint_column_values(endpoint, None, None) if endpoint else {}
token_value = str(row.get("token_value", ""))
rows.append(
{
"item": str(len(rows) + 1),
"remote": str(row.get("remote", "")),
"kind": str(row.get("url_kind", "")),
"server": str(row.get("type", "")),
"proto": endpoint_values.get("scheme", ""),
"host": endpoint_values.get("host", ""),
"owner": str(row.get("org", "")),
"repo_name": str(row.get("repo", "")),
"user": str(row.get("user", "")),
"token": short_secret(token_value) if token_value else "",
"result": str(row.get("result", "")),
"url": str(row.get("url", "")),
}
)
return rows
def fallback_project_by_endpoint(
endpoint_names: list[str],
repo_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None,
) -> dict[str, dict[str, str]]:
fallback: dict[str, dict[str, str]] = {}
for row in scan_rows or []:
endpoint = row.get("endpoint", "")
if endpoint and endpoint not in fallback:
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
for endpoint in endpoint_names:
if endpoint in fallback:
continue
repo_entry = repo_servers.get(endpoint)
if repo_entry is None:
continue
for row in repo_entry.get("tokens", []) + repo_entry.get("plain_remotes", []):
fallback[endpoint] = {
"remote": row.get("remote", ""),
"owner": row.get("org", ""),
"repo_name": row.get("repo", ""),
}
break
return fallback
def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: bool = False) -> object:
headers = {"Accept": "application/json"}
if basic:
raw_credentials = f"{user_name}:{token_value}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw_credentials).decode("ascii")
else:
headers["Authorization"] = f"token {token_value}"
request = urlrequest.Request(api_url, headers=headers)
with urlrequest.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def scope_mask(scopes: list[str]) -> str:
scope_set = {scope.strip().lower() for scope in scopes if scope.strip()}
if "all" in scope_set:
return "w" * len(SCOPE_FIELDS)
values = []
for _, category in SCOPE_FIELDS:
category_names = SCOPE_CATEGORY_ALIASES.get(category, [category])
if any(f"write:{category_name}" in scope_set for category_name in category_names):
values.append("w")
elif any(f"read:{category_name}" in scope_set for category_name in category_names):
values.append("r")
else:
values.append("-")
return "".join(values)
def org_permission_mask(org_permissions: dict) -> str:
return "".join(
[
"+" if org_permissions.get("is_owner") else "-",
"+" if org_permissions.get("is_admin") else "-",
"+" if org_permissions.get("can_write") else "-",
"+" if org_permissions.get("can_read") else "-",
"+" if org_permissions.get("can_create_repository") else "-",
]
)
def repo_permission_mask(repo_permissions: dict) -> str:
permission = str(repo_permissions.get("permission", "none")).lower()
levels = {
"owner": "++++",
"admin": "-+++",
"write": "--++",
"read": "---+",
}
return levels.get(permission, "----")
def load_gitea_authz(
endpoint: str,
token_id: str,
token_value: str,
expires_at: str,
owner: str,
repo_name: str,
) -> dict:
api_base = endpoint.rstrip("/") + "/api/v1"
result = {
"ok": False,
"valid": "!",
"scope_mask": "!!!!!!!!!",
"org_mask": "!!!!!",
"repo_mask": "!!!!",
"user": "",
}
try:
user_info = api_get_json(f"{api_base}/user", token_value)
user_name = str(user_info.get("login", "")) if isinstance(user_info, dict) else ""
result["user"] = user_name
result["valid"] = valid_label(True, expires_at)
if user_name:
try:
tokens_url = f"{api_base}/users/{quote(user_name, safe='')}/tokens"
token_list = api_get_json(tokens_url, token_value, user_name=user_name, basic=True)
token_last8 = token_value[-8:]
token_entry = next(
(
entry
for entry in token_list
if isinstance(entry, dict) and entry.get("token_last_eight") == token_last8
),
None,
)
scopes = token_entry.get("scopes", []) if isinstance(token_entry, dict) else []
if not isinstance(scopes, list):
scopes = []
result["scope_mask"] = scope_mask([str(scope) for scope in scopes])
except (OSError, ValueError, urlerror.URLError):
result["scope_mask"] = "?????????"
if user_name and owner:
org_url = f"{api_base}/users/{quote(user_name, safe='')}/orgs/{quote(owner, safe='')}/permissions"
org_permissions = api_get_json(org_url, token_value)
if isinstance(org_permissions, dict):
result["org_mask"] = org_permission_mask(org_permissions)
if user_name and owner and repo_name:
repo_url = (
f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}"
f"/collaborators/{quote(user_name, safe='')}/permission"
)
repo_permissions = api_get_json(repo_url, token_value)
if isinstance(repo_permissions, dict):
result["repo_mask"] = repo_permission_mask(repo_permissions)
result["ok"] = True
return result
except urlerror.HTTPError as error:
result["valid"] = "invalid" if error.code in {401, 403} else "!"
return result
except (OSError, ValueError, urlerror.URLError):
return result
def print_token_item_rows(
endpoint_names: list[str],
repo_servers: dict[str, dict],
store_servers: dict[str, dict],
scan_rows: list[dict[str, str]] | None = None,
) -> dict[str, int]:
status_names = ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"]
status_counts = {status_name: 0 for status_name in status_names}
for endpoint in endpoint_names:
repo_entry = repo_servers.get(endpoint)
store_entry = store_servers.get(endpoint)
status_name, _, _ = compare_server_entries(repo_entry, store_entry)
if status_name in status_counts:
status_counts[status_name] += 1
store_rows = token_store_rows(store_servers)
store_by_remote: dict[tuple[str, str], dict[str, str]] = {}
for row in store_rows:
if row["remote"]:
store_by_remote.setdefault((row["endpoint"], row["remote"]), row)
endpoint_values_by_name = {
endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint))
for endpoint in endpoint_names
}
project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows)
used_store_keys: set[tuple[str, str, str]] = set()
token_rows: list[dict[str, str]] = []
def append_token_row(
endpoint: str,
user_name: str,
remote_name: str,
token_id: str,
token_value: str,
marker: str,
owner: str,
repo_name: str,
store_row: dict | None = None,
) -> None:
endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None))
valid_value = store_row.get("valid", "?") if store_row else "?"
scope_value = store_row.get("scope_mask", "?????????") if store_row else "?????????"
org_value = store_row.get("org_mask", "?????") if store_row else "?????"
repo_value = store_row.get("repo_mask", "????") if store_row else "????"
token_rows.append(
{
"item": str(len(token_rows) + 1),
"server": endpoint_values["server"],
"proto": endpoint_values["scheme"],
"host": endpoint_values["host"],
"owner": owner,
"repo_name": repo_name,
"user": user_name,
"remote": remote_name,
"token_ref": format_token_ref(token_id, marker),
"token": short_secret(token_value),
"valid": valid_value or "?",
"scope_mask": scope_value or "?????????",
"org_mask": org_value or "?????",
"repo_mask": repo_value or "????",
}
)
for row in repo_token_rows(endpoint_names, repo_servers, scan_rows):
endpoint = row["endpoint"]
token_value = row["token_value"]
store_row = store_by_remote.get((endpoint, row["remote"]))
token_id = row["token_id"]
expires_at = store_row.get("expires_at", "") if store_row else ""
marker = "R"
synced_store_row = None
if store_row:
repo_fields_match = (
store_row["token_id"] == row["token_id"]
and store_row["user"] == row.get("user", "")
and store_row["token_value"] == token_value
and store_row["owner"] == row["owner"]
and store_row["repo_name"] == row["repo_name"]
)
if repo_fields_match:
used_store_keys.add((endpoint, store_row["token_id"], store_row["remote"]))
synced_store_row = store_row
marker = "!" if store_row.get("valid") in {"!", "invalid"} else "*"
append_token_row(
endpoint,
synced_store_row.get("user", "") if synced_store_row else row.get("user", ""),
row["remote"],
token_id,
token_value,
marker,
row["owner"],
row["repo_name"],
synced_store_row,
)
for row in store_rows:
store_key = (row["endpoint"], row["token_id"], row["remote"])
if store_key in used_store_keys:
continue
fallback = project_fallback.get(row["endpoint"], {})
append_token_row(
row["endpoint"],
row["user"],
row["remote"],
row["token_id"],
row["token_value"],
"S",
row["owner"] or fallback.get("owner", ""),
row["repo_name"] or fallback.get("repo_name", ""),
)
print_fixed_table("tokens", TOKEN_COLUMNS, token_rows)
return status_counts
def resolve_token_from_store(
token_data: dict,
endpoint: str | None,
remote_id: str | None,
user_name: str | None,
) -> dict:
candidates = []
for token_record in token_data.get("tokens", []):
token_endpoint = token_server_endpoint(token_record)
if endpoint and token_endpoint != endpoint:
continue
if remote_id and token_record_id(token_record) != remote_id:
continue
if user_name and token_record.get("user") != user_name:
continue
candidates.append(token_record)
if not candidates:
raise SystemExit("No matching token in tokens.json.")
if len(candidates) > 1:
available = ", ".join(
sorted(
f"{token_server_endpoint(token_record)}:{token_record_id(token_record)}"
for token_record in candidates
)
)
raise SystemExit(f"Multiple matching tokens. Pass --server/--token-name or --remote. Available: {available}")
return candidates[0]
def sanitize_remote_url(remote_url: str) -> tuple[str, bool]:
split_url = urlsplit(remote_url)
if split_url.scheme not in {"http", "https"} or split_url.hostname is None:
raise SystemExit(f"Only http/https remote URLs are supported: {remote_url}")
_, port_value = endpoint_from_split(split_url)
default_port = default_port_for_scheme(split_url.scheme)
netloc = split_url.hostname
if port_value is not None and port_value != default_port:
netloc = f"{netloc}:{port_value}"
had_credentials = split_url.username is not None or split_url.password is not None
sanitized_url = urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment))
return sanitized_url, had_credentials
def credentialed_remote_url(remote_url: str, user_name: str, token_value: str) -> str:
sanitized_url, _ = sanitize_remote_url(remote_url)
split_url = urlsplit(sanitized_url)
_, port_value = endpoint_from_split(split_url)
default_port = default_port_for_scheme(split_url.scheme)
host_part = split_url.hostname or ""
if port_value is not None and port_value != default_port:
host_part = f"{host_part}:{port_value}"
netloc = f"{quote(user_name, safe='')}:{quote(token_value, safe='')}@{host_part}"
return urlunsplit((split_url.scheme, netloc, split_url.path, split_url.query, split_url.fragment))
def remote_url_from_token_record(token_record: dict) -> str:
endpoint = token_server_endpoint(token_record).rstrip("/")
owner = str(token_record.get("org", "")).strip("/")
repo_name = str(token_record.get("repo", "")).strip("/")
if not endpoint or not owner or not repo_name:
return ""
if not repo_name.endswith(".git"):
repo_name = f"{repo_name}.git"
return f"{endpoint}/{quote(owner, safe='')}/{quote(repo_name, safe='')}"
def remote_urls_point_to_same_repo(left_url: str, right_url: str) -> bool:
try:
left_sanitized, _ = sanitize_remote_url(left_url)
right_sanitized, _ = sanitize_remote_url(right_url)
except SystemExit:
return False
return left_sanitized.rstrip("/") == right_sanitized.rstrip("/")
def find_origin_rename_candidate(
config: WorkspaceConfig,
repo_root: Path,
target_remote: str,
target_url: str,
) -> tuple[str, str] | None:
origin_remote = config.git.origin_remote
if not origin_remote or origin_remote == target_remote:
return None
if git_capture(repo_root, ["remote", "get-url", target_remote]) is not None:
return None
origin_url = git_capture(repo_root, ["remote", "get-url", origin_remote])
if not origin_url:
return None
if not remote_urls_point_to_same_repo(origin_url, target_url):
return None
return origin_remote, origin_url
def resolve_repo_argument(repo_arg: str | None) -> Path:
return Path(repo_arg).expanduser().resolve() if repo_arg else Path.cwd().resolve()
def resolve_launcher_repo_argument(repo_arg: str | None) -> Path:
return Path(repo_arg).expanduser().resolve() if repo_arg else Path(__file__).resolve().parent
def safe_remote_url_label(remote_url: str | None) -> str:
if not remote_url:
return ""
split_url = urlsplit(remote_url)
if split_url.scheme in {"http", "https"} and split_url.hostname is not None:
return sanitize_remote_url(remote_url)[0]
return remote_url
def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
report = scan_repo_remotes(config, repo_path)
print_fixed_table("remotes", TOKEN_SCAN_COLUMNS, scan_remote_rows(report, args.server))
def run_tokens_compare(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
report = scan_repo_remotes(config, repo_path)
repo_root, repo_servers = collect_repo_server_entries(config, repo_path)
endpoint_names = sorted(set(repo_servers) | set(store_servers))
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
def run_tokens_list(config: WorkspaceConfig, args: argparse.Namespace) -> None:
rows: list[dict[str, str]] = []
if args.target in {"store", "both"}:
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
rows.extend(list_store_rows(store_servers, args.server))
if args.target in {"remote", "both"}:
repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo))
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {resolve_launcher_repo_argument(args.repo)}")
rows.extend(list_remote_rows(repo_servers, args.server))
for index, row in enumerate(rows, start=1):
row["item"] = str(index)
print_fixed_table("tokens", TOKEN_LIST_COLUMNS, rows)
def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None:
token_data = load_token_store(config, write_normalized=False)
servers = token_store_servers(token_data)
if args.server:
server_entry = dict(store_servers_from_tokens(token_data)).get(args.server)
if server_entry is None:
raise SystemExit(f"Missing server endpoint in token store: {args.server}")
servers = [(args.server, server_entry)]
if not servers:
print(f"token_path\t{config.token_path}")
print("servers\t0")
return
print(f"token_path\t{config.token_path}")
for index, (endpoint, server_entry) in enumerate(servers):
if index:
print()
print(f"endpoint\t{endpoint}")
print(f"type\t{server_entry.get('type', 'unknown')}")
print(f"scheme\t{server_entry.get('scheme', '')}")
print(f"host\t{server_entry.get('host', '')}")
print(f"port\t{server_entry.get('port', '')}")
print(f"tokens\t{count_server_tokens(server_entry)}")
for row in sorted(server_entry.get("tokens", []), key=lambda item: item.get("token_id", "")):
token_value = row.get("token_value", "")
secret_value = token_value if args.show_secrets else mask_secret(token_value)
print(f"id\t{row.get('token_id', '')}\t{secret_value}")
for field_name in ["user", "valid", "scope_mask", "expires_at"]:
field_value = row.get(field_name, "")
if field_value:
print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}")
if row.get("remote"):
print(
"remote\t"
f"{row.get('token_id', '')}\t{row.get('remote', '')}\t"
f"{row.get('owner', '')}\t{row.get('repo_name', '')}"
)
for field_name in ["org_mask", "repo_mask"]:
field_value = row.get(field_name, "")
if field_value:
print(f"{field_name}\t{row.get('token_id', '')}\t{field_value}")
def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_root, repo_servers = collect_repo_server_entries(config, resolve_launcher_repo_argument(args.repo))
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
endpoint_names = sorted(set(repo_servers) | set(store_servers))
if args.server:
if args.server not in set(endpoint_names):
raise SystemExit(f"Missing server endpoint in repo/store: {args.server}")
endpoint_names = [args.server]
print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names))
print()
status_counts = print_token_item_rows(endpoint_names, repo_servers, store_servers)
print()
print_status_counts(status_counts)
def print_token_action_result(result: dict[str, str]) -> None:
for field_name in ["repo_root", "token_path", "remote", "server", "user", "org", "repo", "valid", "url", "removed", "status"]:
field_value = result.get(field_name)
if field_value is not None:
print(f"{field_name}\t{field_value}")
def remove_token_from_store(
config: WorkspaceConfig,
remote_id: str,
endpoint: str | None = None,
dry_run: bool = False,
) -> dict[str, str]:
token_data = load_token_store(config, write_normalized=False)
matches = []
for index, token_record in enumerate(token_data.get("tokens", [])):
if token_record_id(token_record) != remote_id:
continue
if endpoint and token_server_endpoint(token_record) != endpoint:
continue
matches.append((index, token_record))
if not matches:
return {
"token_path": str(config.token_path),
"remote": remote_id,
"server": endpoint or "",
"removed": "0",
"status": "missing",
}
if len(matches) > 1 and not endpoint:
available = ", ".join(sorted(token_server_endpoint(token_record) for _, token_record in matches))
raise SystemExit(f"Multiple tokens with id '{remote_id}'. Pass --server. Available: {available}")
removed_indexes = {index for index, _ in matches}
first_record = matches[0][1]
if not dry_run:
token_data["tokens"] = [
token_record
for index, token_record in enumerate(token_data.get("tokens", []))
if index not in removed_indexes
]
write_token_store(config, token_data)
return {
"token_path": str(config.token_path),
"remote": remote_id,
"server": token_server_endpoint(first_record),
"user": str(first_record.get("user", "")),
"org": str(first_record.get("org", "")),
"repo": str(first_record.get("repo", "")),
"removed": str(len(matches)),
"status": "dry-run" if dry_run else "removed",
}
def remove_git_remote(repo_path: Path, remote_id: str, dry_run: bool = False) -> dict[str, str]:
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
remote_url = git_capture(repo_root, ["remote", "get-url", remote_id])
if not remote_url:
return {
"repo_root": str(repo_root),
"remote": remote_id,
"removed": "0",
"status": "missing",
}
if not dry_run:
subprocess.run(
["git", "-C", str(repo_root), "remote", "remove", remote_id],
check=True,
)
return {
"repo_root": str(repo_root),
"remote": remote_id,
"url": safe_remote_url_label(remote_url),
"removed": "1",
"status": "dry-run" if dry_run else "removed",
}
def run_tokens_remove(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.target == "store":
result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(result)
return
if args.target == "remote":
result = remove_git_remote(resolve_launcher_repo_argument(args.repo), args.remote_id, args.dry_run)
print_token_action_result(result)
return
if args.target == "both":
repo_path = resolve_launcher_repo_argument(args.repo)
if not args.dry_run:
remove_token_from_store(config, args.remote_id, args.server, dry_run=True)
remote_result = remove_git_remote(repo_path, args.remote_id, args.dry_run)
print_token_action_result(remote_result)
print()
store_result = remove_token_from_store(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(store_result)
return
raise SystemExit(f"Unsupported tokens remove target: {args.target}")
def run_tokens_sync(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_launcher_repo_argument(args.repo)
if args.direction == "remote":
result = sync_token_from_remote(config, repo_path, args.remote_id, args.dry_run)
print_token_action_result(result)
return
if args.direction == "store":
args.repo = str(repo_path)
args.remote = args.remote_id
args.token_name = args.remote_id
args.auto_rename_origin = True
run_tokens_write(config, args)
return
raise SystemExit(f"Unsupported tokens sync direction: {args.direction}")
def run_tokens_add(config: WorkspaceConfig, args: argparse.Namespace) -> None:
server_url = (args.server or config.git.base_url).rstrip("/")
server_info = server_info_from_url(config, server_url)
if server_info is None:
raise SystemExit(f"Only http/https server URLs are supported: {server_url}")
token_data = load_token_store(config)
remote_id = args.token_id
if args.remote and args.remote != remote_id:
raise SystemExit("Token id must match --remote because tokens.json is keyed 1:1 by git remote name.")
existing_token = find_token_record(token_data, server_info["endpoint"], remote_id)
if existing_token is not None:
raise SystemExit(f"Token already exists in tokens.json: {server_info['endpoint']}:{remote_id}")
token_record = {
"id": remote_id,
"value": args.value or "",
"server": server_record_from_info(server_info),
"org": args.org or "",
"repo": args.repo_name or "",
}
if args.user:
token_record["user"] = args.user
print(f"token_path\t{config.token_path}")
print(f"server\t{server_info['endpoint']}")
print(f"id\t{remote_id}")
print(f"value\t{'set' if token_record['value'] else 'empty'}")
print(f"remote\t{remote_id}")
print(f"status\t{'dry-run' if args.dry_run else 'added'}")
if args.dry_run:
return
token_data.setdefault("tokens", []).append(token_record)
write_token_store(config, token_data)
def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None:
repo_path = resolve_repo_argument(args.repo)
repo_root = git_repo_root(repo_path)
if repo_root is None:
raise SystemExit(f"Missing git repo at path: {repo_path}")
if not args.remote:
raise SystemExit("Pass --remote for tokens write.")
token_data = load_token_store(config)
existing_remote_url = git_capture(repo_root, ["remote", "get-url", args.remote])
target_url = args.url or existing_remote_url
target_endpoint = None
if target_url:
target_server_info = server_info_from_url(config, target_url)
if target_server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {target_url}")
target_endpoint = target_server_info["endpoint"]
token_record = resolve_token_from_store(
token_data,
args.server or target_endpoint,
args.token_name or args.remote,
args.user,
)
server_endpoint = token_server_endpoint(token_record)
remote_id = token_record_id(token_record)
user_name = str(token_record.get("user", ""))
token_value = str(token_record.get("value", ""))
if not token_value:
raise SystemExit(f"Token value is empty in tokens.json: {server_endpoint}:{remote_id}")
if not user_name:
raise SystemExit(f"Token user is empty in tokens.json: {server_endpoint}:{remote_id}")
if not target_url:
target_url = remote_url_from_token_record(token_record)
if not target_url:
raise SystemExit(
f"Missing target remote URL and tokens.json has no org/repo for: {server_endpoint}:{remote_id}"
)
rename_candidate = None
if getattr(args, "auto_rename_origin", False) and existing_remote_url is None:
rename_candidate = find_origin_rename_candidate(config, repo_root, args.remote, target_url)
if rename_candidate:
_, existing_remote_url = rename_candidate
final_url = credentialed_remote_url(target_url, user_name, token_value)
status = "added" if existing_remote_url is None else "updated"
existing_credentials = remote_credentials(existing_remote_url) if existing_remote_url else None
if existing_credentials == (user_name, token_value):
status = "renamed" if rename_candidate else "unchanged"
elif existing_credentials is not None and not args.replace:
raise SystemExit(
f"Remote '{args.remote}' already has different credentials. "
"Pass --replace to overwrite them."
)
print(f"repo_root\t{repo_root}")
print(f"remote\t{args.remote}")
if rename_candidate:
print(f"renamed_from\t{rename_candidate[0]}")
print(f"server\t{server_endpoint}")
print(f"user\t{user_name}")
print(f"id\t{remote_id}")
print(f"status\t{status}")
print(f"url\t{sanitize_remote_url(final_url)[0]}")
if args.dry_run or status == "unchanged":
return
if rename_candidate:
subprocess.run(
["git", "-C", str(repo_root), "remote", "rename", rename_candidate[0], args.remote],
check=True,
)
set_git_remote(repo_root, args.remote, final_url)
def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.remote_id:
result = hydrate_token_from_api(config, args.remote_id, args.server, args.dry_run)
print_token_action_result(result)
return
if args.from_source == "remotes":
if args.dry_run:
run_tokens_scan(config, args)
return
repo_path = resolve_repo_argument(args.repo)
report = sync_tokens_from_repo(config, repo_path)
repo_root, repo_servers = collect_repo_server_entries(config, repo_path)
token_data = load_token_store(config, write_normalized=False)
store_servers = store_servers_from_tokens(token_data)
endpoint_names = sorted(set(repo_servers) | set(store_servers))
print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"])
return
if args.from_source == "store":
run_tokens_write(config, args)
return
raise SystemExit("Pass REMOTE_ID for API metadata refresh or use legacy --from remotes|store.")
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 source_repo_name
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 answer_url_for_card(config: WorkspaceConfig, repo_name: str) -> str:
return f"{config.git.base_url}/{config.git.answer_org}/{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 source_url_for_card(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> str:
source_url = git_capture(source_card_path, ["remote", "get-url", config.git.source_remote])
if source_url:
return source_url
source_url = git_capture(source_card_path, ["remote", "get-url", config.git.origin_remote])
if source_url:
return source_url
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,
answer_remote: str,
answer_url: str,
dry_run: bool = False,
) -> tuple[str, dict]:
token_data = load_token_store(config, write_normalized=not dry_run)
answer_server_info = server_info_from_url(config, answer_url)
if answer_server_info is None:
raise SystemExit(f"Only http/https remote URLs are supported: {answer_url}")
source_token = resolve_token_from_store(token_data, answer_server_info["endpoint"], source_remote, None)
if dry_run:
return "dry-run", source_token
status = register_token(
token_data,
answer_server_info,
answer_remote,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
hydrate=False,
reset_api_metadata=True,
)
write_token_store(config, token_data)
return status, source_token
def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_path: Path) -> Path:
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:
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]:
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)
if target_path.exists() and git_repo_root(target_path) is None:
raise SystemExit(f"Workspace card path exists but is not a git repo: {target_path}")
cloned = False
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", 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", card_config.git.origin_remote, card_config.git.source_remote],
check=True,
)
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(
["git", "-C", str(target_path), "rev-parse", "--verify", "--quiet", source_branch],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
if local_branch_exists:
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"{card_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(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
dry_run=dry_run,
)
answer_remote_status = "dry-run"
if not dry_run:
answer_remote_url = credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
)
answer_remote_status = set_git_remote(target_path, card_config.git.answer_remote, answer_remote_url)
return {
"series": series_name,
"card": card_info.card_id,
"repo": repo_name,
"source": source_url,
"workspace": str(target_path),
"source_remote": card_config.git.source_remote,
"source_branch": source_branch,
"answer_remote": card_config.git.answer_remote,
"answer_org": card_config.git.answer_org,
"answer_repo": repo_name,
"answer_url": answer_url,
"token": card_config.git.answer_remote,
"token_status": token_status,
"answer_remote_status": answer_remote_status,
"status": "dry-run" if dry_run else "ready",
}
def print_key_values(values: dict[str, str]) -> None:
for key, value in values.items():
print(f"{key}\t{value}")
def task_name_from_path(path: Path) -> str:
if path.is_file():
return path.stem
return path.name
def task_entries(card_path: Path) -> list[TaskEntry]:
entries: dict[str, TaskEntry] = {}
task_root = card_path / "src" / "tasks"
if task_root.is_dir():
for path in sorted(task_root.iterdir()):
name = task_name_from_path(path)
if (path.is_file() or path.is_dir()) and name.startswith("task"):
entries[name] = TaskEntry(name=name, path=path)
for path in sorted(card_path.iterdir()):
name = task_name_from_path(path)
if (path.is_file() or path.is_dir()) and name.startswith("task"):
entries.setdefault(name, TaskEntry(name=name, path=path))
return [entries[name] for name in sorted(entries)]
def task_names(card_path: Path) -> list[str]:
return [entry.name for entry in task_entries(card_path)]
def task_branch_part(task_name: str) -> str:
match = re.search(r"(?i)task[-_]?(\d+)", task_name)
if match is None:
match = re.fullmatch(r"(?i)t(\d+)", task_name.strip())
if match is None:
match = re.search(r"(\d+)", task_name)
if match is None:
raise SystemExit(f"Task name does not contain a task number: {task_name}")
return f"T{int(match.group(1))}"
def resolve_submission_task(config: WorkspaceConfig, card_path: Path, task_arg: str | None) -> str:
task_selector = task_arg or config.defaults.get("task")
if not task_selector:
raise SystemExit("Missing task selector. Pass --task or set defaults.task in workspace.json.")
names = task_names(card_path)
if not names:
return task_selector
matches = [name for name in names if name == task_selector]
if not matches:
try:
selector_part = task_branch_part(task_selector)
except SystemExit:
selector_part = ""
number_matches = []
for name in names:
try:
if selector_part and task_branch_part(name) == selector_part:
number_matches.append(name)
except SystemExit:
pass
matches = number_matches
if not matches:
matches = [name for name in names if task_selector in name]
if not matches:
raise SystemExit(f"Missing task '{task_selector}' in card: {card_path}")
if len(matches) > 1:
raise SystemExit(f"Ambiguous task '{task_selector}'. Matches: {', '.join(matches)}")
return matches[0]
def store_user_for_submission(
config: WorkspaceConfig,
source_url: str,
answer_url: str,
required: bool = True,
) -> str:
token_data = load_token_store(config)
endpoints = []
for remote_url in [source_url, answer_url, config.git.base_url]:
server_info = server_info_from_url(config, remote_url)
if server_info is None:
continue
endpoint = server_info["endpoint"]
if endpoint not in endpoints:
endpoints.append(endpoint)
for endpoint in endpoints:
for remote_id in [config.git.source_remote, config.git.answer_remote]:
token_record = find_token_record(token_data, endpoint, remote_id)
if token_record and token_record.get("user"):
return slug_value(str(token_record["user"]), "token user")
users = {
str(token_record.get("user", ""))
for token_record in token_data.get("tokens", [])
if token_server_endpoint(token_record) in endpoints and token_record.get("user")
}
if len(users) == 1:
return slug_value(next(iter(users)), "token user")
if required:
raise SystemExit("Missing --nick and no unique user in tokens.json for the answer branch.")
return ""
def submission_branch_name(user_name: str, task_name: str) -> str:
return branch_value(f"{slug_value(user_name, 'student user')}{task_branch_part(task_name)}a")
def set_branch_upstream(repo_path: Path, branch_name: str, remote_name: str, dry_run: bool = False) -> str:
expected_merge = f"refs/heads/{branch_name}"
current_remote = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.remote"])
current_merge = git_capture(repo_path, ["config", "--get", f"branch.{branch_name}.merge"])
if current_remote == remote_name and current_merge == expected_merge:
return "unchanged"
if dry_run:
return "dry-run"
subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.remote", remote_name], check=True)
subprocess.run(["git", "-C", str(repo_path), "config", f"branch.{branch_name}.merge", expected_merge], check=True)
return "updated" if current_remote or current_merge else "set"
def print_card_tasks(config: WorkspaceConfig, series_name: str, card_name: str) -> None:
card_path = resolve_workspace_card_path(config, series_name, card_name)
for entry in task_entries(card_path):
print(f"{series_name}\t{card_label_from_repo_name(card_path.name)}\t{entry.name}\t{entry.path}")
def print_card_task(config: WorkspaceConfig, series_name: str, card_name: str, task_name: str) -> None:
card_path = resolve_workspace_card_path(config, series_name, card_name)
matches = [entry for entry in task_entries(card_path) if entry.name == task_name or task_name in entry.name]
if not matches:
raise SystemExit(f"Missing task '{task_name}' in card: {card_path}")
if len(matches) > 1:
raise SystemExit(f"Ambiguous task '{task_name}'. Matches: {', '.join(entry.name for entry in matches)}")
task_entry = matches[0]
print(f"series\t{series_name}")
print(f"card\t{card_label_from_repo_name(card_path.name)}")
print(f"task\t{task_entry.name}")
print(f"card_path\t{card_path}")
print(f"task_path\t{task_entry.path}")
def switch_card_task(
config: WorkspaceConfig,
series_name: str,
card_name: str,
task_selector: str,
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(card_config, card_path, series_name, card_name)
source_repo_name = repo_name_from_url(source_url)
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(card_config, card_path)
branch_exists = subprocess.run(
["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
).returncode == 0
token_status = "dry-run"
answer_remote_status = "dry-run"
if not dry_run:
token_status, source_token = ensure_answer_token_from_source(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
)
answer_remote_status = set_git_remote(
card_path,
card_config.git.answer_remote,
credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
),
)
if dry_run:
status = "dry-run"
elif current_branch == branch_name:
status = "unchanged"
elif branch_exists:
subprocess.run(["git", "-C", str(card_path), "switch", branch_name], check=True)
status = "switched"
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, card_config.git.answer_remote, dry_run=dry_run)
return {
"series": series_name,
"card": card_info.card_id,
"task": task_name,
"student_user": student_user,
"branch": branch_name,
"answer_remote": card_config.git.answer_remote,
"answer_url": answer_url,
"token_status": token_status,
"answer_remote_status": answer_remote_status,
"upstream": f"{card_config.git.answer_remote}/{branch_name}",
"upstream_status": upstream_status,
"workspace": str(card_path),
"status": status,
}
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_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.card_id for card in cards)}")
def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str) -> None:
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_info.card_id}")
print(f"repo\t{repo_name}")
print(f"source_path\t{card_info.source_path or ''}")
print(f"workspace_path\t{target_path}")
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))}")
def run_series(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.series_command == "list":
print_series(config)
return
if args.series_command == "show":
print_series_details(config, args.series)
return
if args.series_command == "fetch":
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
if args.series_command != "cards":
raise SystemExit(f"Unsupported series command: {args.series_command}")
if args.cards_command == "list":
print_cards(config, args.series)
return
if args.cards_command == "show":
print_card_details(config, args.series, args.card)
return
if args.cards_command == "fetch":
print_key_values(fetch_card_to_workspace(config, args.series, args.card, dry_run=args.dry_run))
return
if args.cards_command == "submission":
print_submission_plan(config, args)
return
if args.cards_command != "tasks":
raise SystemExit(f"Unsupported series cards command: {args.cards_command}")
if args.tasks_command == "list":
print_card_tasks(config, args.series, args.card)
return
if args.tasks_command == "show":
print_card_task(config, args.series, args.card, args.task)
return
if args.tasks_command == "switch":
print_key_values(switch_card_task(config, args.series, args.card, args.task, args.branch, args.dry_run))
return
raise SystemExit(f"Unsupported series cards tasks command: {args.tasks_command}")
def resolve_task_command_selector(
config: WorkspaceConfig,
values: list[str],
needs_task: bool = False,
) -> tuple[str, str, str | None]:
if not needs_task:
if len(values) > 2:
raise SystemExit("Pass at most '<series> <card>' for tasks list.")
series_arg = values[0] if values else None
card_arg = values[1] if len(values) == 2 else None
series_name, card_name = resolve_selector(config, series_arg, card_arg)
return series_name, card_name, None
if not values:
raise SystemExit("Missing task. Use: ./rvctl tasks switch 4")
if len(values) > 3:
raise SystemExit("Pass '<task>', '<card> <task>' or '<series> <card> <task>'.")
if len(values) == 1:
series_name, card_name = resolve_selector(config, None, None)
return series_name, card_name, values[0]
if len(values) == 2:
series_name, card_name = resolve_selector(config, values[0], None)
return series_name, card_name, values[1]
series_name, card_name = resolve_selector(config, values[0], values[1])
return series_name, card_name, values[2]
def run_tasks(config: WorkspaceConfig, args: argparse.Namespace) -> None:
if args.tasks_command == "list":
series_name, card_name, _ = resolve_task_command_selector(config, args.selector)
print_card_tasks(config, series_name, card_name)
return
if args.tasks_command == "show":
series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True)
print_card_task(config, series_name, card_name, task_name or "")
return
if args.tasks_command == "switch":
series_name, card_name, task_name = resolve_task_command_selector(config, args.selector, needs_task=True)
print_key_values(switch_card_task(config, series_name, card_name, task_name or "", args.branch, args.dry_run))
return
raise SystemExit(f"Unsupported tasks command: {args.tasks_command}")
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(card_config, card_path)
class_name = slug_value(args.class_name, "class")
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(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(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{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{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, card_config.git.source_remote, source_url)
token_status, source_token = ensure_answer_token_from_source(
card_config,
card_config.git.source_remote,
card_config.git.answer_remote,
answer_url,
)
answer_status = set_git_remote(
card_path,
card_config.git.answer_remote,
credentialed_remote_url(
answer_url,
str(source_token.get("user", "")),
str(source_token.get("value", "")),
),
)
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 {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 {card_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],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def build_container_command(
config: WorkspaceConfig,
selector: str,
card_path: Path,
instance: str,
) -> str:
tool_root = config.tools_root
rv_script = tool_root / "rv"
if not rv_script.is_file():
candidates = "\n".join(f" - {path}" for path in config.tools_root_candidates)
raise SystemExit(f"Missing rv launcher. Checked:\n{candidates}")
env_parts = [
f"WORKSPACE_ROOT={shlex.quote(str(config.workspace_root))}",
f"RV_REPO_PATH={shlex.quote(str(card_path))}",
f"RV_CARD={shlex.quote(selector)}",
f"RV_INSTANCE={shlex.quote(instance)}",
]
return " && ".join(
[
f"cd {shlex.quote(str(tool_root))}",
" ".join(env_parts + ["bash", "./rv", "shell"]),
]
)
def run_tmux_container(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)
session_name = args.session or config.defaults.get("tmux_session", "rv-workspace")
window_name = args.window or config.defaults.get("tmux_window", "rv")
instance = args.instance or config.defaults.get("instance", "shell")
if tmux_target_exists(session_name):
raise SystemExit(
f"tmux session already exists: {session_name}\n"
"Use another --session name or remove the existing session first."
)
shell_command = build_container_command(config, selector, card_path, instance)
tmux_command = [
"tmux",
"new-session",
"-d",
"-s",
session_name,
"-n",
window_name,
f"bash -lc {shlex.quote(shell_command)}",
]
if args.dry_run:
print("Selector:", selector)
print("Card path:", card_path)
print("Tools root:", config.tools_root)
print("Pane 0 command:")
print(shell_command)
print("tmux command:")
print(shlex.join(tmux_command))
return
subprocess.run(tmux_command, check=True)
if args.attach:
subprocess.run(["tmux", "attach", "-t", session_name], check=True)
return
print(f"Created tmux session: {session_name}")
print(f"Window: {window_name}")
print("Pane 0: container shell")
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 sync_workspace_info(config: WorkspaceConfig, pull_existing: bool = True) -> str:
info_root = workspace_info_root(config)
info_root.parent.mkdir(parents=True, exist_ok=True)
if (info_root / ".git").is_dir():
if pull_existing:
subprocess.run(["git", "-C", str(info_root), "pull", "--ff-only"], check=True)
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "pulled"
if config.workspace_info_path.is_file():
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "existing"
raise SystemExit(f"Workspace info repo exists but manifest is missing: {config.workspace_info_path}")
if info_root.exists():
raise SystemExit(f"Workspace info path exists but is not a git repo: {info_root}")
print(f"workspace-info missing; cloning {config.workspace_info_url}", file=sys.stderr)
subprocess.run(["git", "clone", config.workspace_info_url, str(info_root)], check=True)
if not config.workspace_info_path.is_file():
raise SystemExit(f"Workspace info clone does not contain manifest: {config.workspace_info_path}")
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return "cloned"
def ensure_workspace_info(config: WorkspaceConfig) -> None:
if config.workspace_info:
return
if config.workspace_info_path.is_file():
config.workspace_info = json.loads(config.workspace_info_path.read_text(encoding="utf-8"))
return
sync_workspace_info(config, pull_existing=False)
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
sync_workspace_info(config, pull_existing=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}")
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}")
def add_repo_option(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: current working directory.",
)
def add_store_selection_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--server", help="Server endpoint from tokens.json.")
parser.add_argument("--user", help="API user recorded on the token.")
parser.add_argument("--token-name", help="Remote id inside tokens.json, for example 'r1'. Default: --remote.")
def print_table(headers: list[str], rows: list[list[str]]) -> None:
widths = [len(header) for header in headers]
for row in rows:
for index, value in enumerate(row):
widths[index] = max(widths[index], len(value))
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_config_summary(config_path: Path) -> None:
try:
config = load_config(config_path)
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print("Config:")
print_table(
["field", "value"],
[
["config", str(config_path)],
["status", f"unavailable: {error}"],
],
)
return
print("Config:")
print_table(
["field", "value"],
[
["config", str(config.config_path)],
["workspace", str(config.workspace_root)],
["series", str(config.series_root)],
["tokens", str(config.token_path)],
],
)
def print_main_overview(config_path: Path) -> None:
print("RVCTL - launcher workspace RV")
print()
print("Usage:")
print(" ./rvctl <command> [options]")
print(" ./rvctl tokens <command> [options]")
print(" ./rvctl <command> --help")
print()
print("Commands:")
print_table(
["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"],
["list-cards", "[series]", "compat alias for series cards list"],
["tmux-container", "[series] [card]", "start tmux with container in pane 0"],
["submission", "[series] [card] --class K [--task T]", "prepare answer repo and student branch"],
["tokens", "list|compare|cmp|sync|update|remove|rm|add|read|stats|write", "manage tokens.json and Git remote credentials"],
],
)
print()
print("Typical flow:")
print_table(
["step", "command", "result"],
[
["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"],
["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()
print_config_summary(config_path)
print()
print("Docs:")
print(" doc/rvctl.md")
print(" doc/tokens.md")
print(" doc/tokens.schema.json")
print(" doc/workspace.md")
def print_tokens_overview() -> None:
print("RVCTL tokens - token store and Git remote credentials")
print()
print("Usage:")
print(" ./rvctl tokens <command> [options]")
print(" ./rvctl tokens <command> --help")
print()
print("Commands:")
print_table(
["command", "common options", "direction", "purpose"],
[
["list", "store|remote|both", "read-only", "list one source without comparing it"],
["compare/cmp", "[--repo PATH]", "read-only", "compare remote/store with marker and auth masks"],
["add", "REMOTE_ID", "tokens.json", "add an empty token skeleton for manual editing"],
["read", "[--server ENDPOINT]", "tokens.json", "show servers, remote ids and tokens"],
["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"],
["sync remote", "REMOTE_ID [--repo PATH]", "repo -> tokens.json", "copy one remote URL into the store"],
["sync store", "REMOTE_ID [--repo PATH]", "tokens.json -> repo", "write one store record into a remote URL"],
["remove/rm", "store|remote|both REMOTE_ID", "selected", "remove token store record or Git remote"],
["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"],
["update", "REMOTE_ID", "API -> tokens.json", "refresh valid/scope/org/repo metadata"],
],
)
print()
print("Examples:")
print_table(
["case", "command"],
[
["list token store", "./rvctl tokens list store"],
["list launcher remotes", "./rvctl tokens list remote"],
["compare launcher state", "./rvctl tokens cmp"],
["read remote r1 into store", "./rvctl tokens sync remote r1"],
["refresh r1 metadata", "./rvctl tokens update r1"],
["write store r1 to remote", "./rvctl tokens sync store r1 --repo PATH"],
["remove r1 from store", "./rvctl tokens rm store r1"],
["remove Git remote r1", "./rvctl tokens rm remote r1"],
["compare selected card", "./rvctl tokens cmp --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"],
["stats selected card", "./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack"],
["write auth to r1", "./rvctl tokens write --repo PATH --remote r1 --server URL"],
],
)
def extract_config_path(argv: list[str]) -> tuple[Path, list[str]]:
config_path = Path(__file__).with_name("workspace.json")
remaining: list[str] = []
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--config":
if index + 1 >= len(argv):
remaining.append(arg)
index += 1
continue
config_path = Path(argv[index + 1])
index += 2
continue
if arg.startswith("--config="):
config_path = Path(arg.split("=", 1)[1])
index += 1
continue
remaining.append(arg)
index += 1
return config_path, remaining
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="rvctl",
description="rvctl for rv cards and tools.",
)
parser.add_argument(
"--config",
default=Path(__file__).with_name("workspace.json"),
type=Path,
help="Path to workspace.json",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("show-config", help="Print resolved workspace paths.")
subparsers.add_parser("list-series", help="List series directories from series_root.")
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)
tasks_short_list_parser = tasks_subparsers.add_parser("list", help="List tasks from the default or selected card.")
tasks_short_list_parser.add_argument("selector", nargs="*", help="Optional card selector: [card] or [series card].")
tasks_short_show_parser = tasks_subparsers.add_parser("show", help="Show one task from the default or selected card.")
tasks_short_show_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.")
tasks_short_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.")
tasks_short_switch_parser.add_argument("selector", nargs="+", help="Task selector: task, card task, or series card task.")
tasks_short_switch_parser.add_argument("--branch", help="Override the branch name.")
tasks_short_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.")
tmux_parser = subparsers.add_parser(
"tmux-container",
help="Create a tmux session with pane 0 running the container shell for a selected card.",
)
tmux_parser.add_argument("series", nargs="?", help="Series id or full selector like 'inf/bss'.")
tmux_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.")
tmux_parser.add_argument("--session", help="tmux session name.")
tmux_parser.add_argument("--window", help="tmux window name.")
tmux_parser.add_argument("--instance", help="RV_INSTANCE passed to the tool launcher.")
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/bss'.")
submission_parser.add_argument("card", nargs="?", help="Card id or unique fragment, for example 'bss'.")
submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id, for example 'c2025-1a-inf'.")
submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.")
submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.")
submission_parser.add_argument(
"--branch",
help="Override the submission branch name. Defaults to <store-user>T<task-number>a.",
)
submission_parser.add_argument(
"--date",
default=dt.date.today().isoformat(),
help="Lesson date reported in the submission plan. Default: today in YYYY-MM-DD.",
)
submission_parser.add_argument(
"--source-url",
help="Override the source repo URL. By default launcher reads r1 or 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.",
)
series_parser = subparsers.add_parser("series", help="Work with series, cards and tasks.")
series_subparsers = series_parser.add_subparsers(dest="series_command", required=True)
series_subparsers.add_parser("list", help="List available series.")
series_show_parser = series_subparsers.add_parser("show", help="Show one series.")
series_show_parser.add_argument("series", help="Series id, for example 'inf'.")
series_fetch_parser = series_subparsers.add_parser("fetch", help="Fetch all cards in a series.")
series_fetch_parser.add_argument("series", help="Series id, for example 'inf'.")
series_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.")
cards_group_parser = series_subparsers.add_parser("cards", help="Work with cards in a series.")
cards_subparsers = cards_group_parser.add_subparsers(dest="cards_command", required=True)
cards_list_parser = cards_subparsers.add_parser("list", help="List cards in a series.")
cards_list_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_show_parser = cards_subparsers.add_parser("show", help="Show one card.")
cards_show_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_fetch_parser = cards_subparsers.add_parser("fetch", help="Fetch one card into workspace.")
cards_fetch_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_fetch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_fetch_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning.")
cards_submission_parser = cards_subparsers.add_parser("submission", help="Print/apply answer remotes for a card.")
cards_submission_parser.add_argument("series", help="Series id, for example 'inf'.")
cards_submission_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
cards_submission_parser.add_argument("--class", dest="class_name", required=True, help="Class id.")
cards_submission_parser.add_argument("--nick", help="Student nick. Defaults to the user from tokens.json.")
cards_submission_parser.add_argument("--task", help="Task name or unique fragment. Defaults to defaults.task.")
cards_submission_parser.add_argument("--branch", help="Override the submission branch name.")
cards_submission_parser.add_argument("--date", default=dt.date.today().isoformat(), help="Lesson date in YYYY-MM-DD.")
cards_submission_parser.add_argument("--source-url", help="Override source repo URL.")
cards_submission_parser.add_argument("--apply", action="store_true", help="Write remotes into the selected card repo.")
tasks_group_parser = cards_subparsers.add_parser("tasks", help="Work with tasks in a card.")
tasks_subparsers = tasks_group_parser.add_subparsers(dest="tasks_command", required=True)
tasks_list_parser = tasks_subparsers.add_parser("list", help="List tasks in a card.")
tasks_list_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_list_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_show_parser = tasks_subparsers.add_parser("show", help="Show one task.")
tasks_show_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_show_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_show_parser.add_argument("task", help="Task name or unique fragment.")
tasks_switch_parser = tasks_subparsers.add_parser("switch", help="Create or switch to the answer branch for a task.")
tasks_switch_parser.add_argument("series", help="Series id, for example 'inf'.")
tasks_switch_parser.add_argument("card", help="Card id or unique fragment, for example 'bss'.")
tasks_switch_parser.add_argument("task", help="Task name or unique fragment, for example 'task4' or '4'.")
tasks_switch_parser.add_argument("--branch", help="Override the branch name.")
tasks_switch_parser.add_argument("--dry-run", action="store_true", help="Print plan without switching branches.")
tokens_parser = subparsers.add_parser("tokens", help="Manage local token store and repo remotes.")
tokens_subparsers = tokens_parser.add_subparsers(dest="tokens_command", required=True)
tokens_compare_parser = tokens_subparsers.add_parser(
"compare",
aliases=["cmp"],
help="Compare repo remotes with tokens.json and print paired token rows.",
)
tokens_compare_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_list_parser = tokens_subparsers.add_parser(
"list",
help="List token store records, Git remotes, or both without comparing them.",
)
tokens_list_parser.add_argument("target", choices=["store", "remote", "both"], help="Source to list.")
tokens_list_parser.add_argument(
"--repo",
help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.",
)
tokens_list_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_read_parser = tokens_subparsers.add_parser(
"read",
help="Read tokens.json and print stored servers, token ids and remotes.",
)
tokens_read_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_read_parser.add_argument("--show-secrets", action="store_true", help="Print full token values.")
tokens_add_parser = tokens_subparsers.add_parser(
"add",
help="Add an empty token skeleton to tokens.json.",
)
tokens_add_parser.add_argument("token_id", metavar="REMOTE_ID", help="Remote id, for example 'r1' or 'r1a'.")
tokens_add_parser.add_argument("--server", help="Server endpoint. Default: git.base_url from workspace.json.")
tokens_add_parser.add_argument("--value", default="", help="Optional token value. Default: empty for manual editing.")
tokens_add_parser.add_argument("--user", help="Optional API user label.")
tokens_add_parser.add_argument("--remote", help="Compatibility alias. If used, it must match REMOTE_ID.")
tokens_add_parser.add_argument("--org", help="Optional remote organization.")
tokens_add_parser.add_argument("--repo", dest="repo_name", help="Optional remote repository.")
tokens_add_parser.add_argument("--dry-run", action="store_true", help="Print the planned token without writing it.")
tokens_stats_parser = tokens_subparsers.add_parser(
"stats",
help="Print per-server token statistics from tokens.json.",
)
tokens_stats_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_stats_parser.add_argument("--server", help="Filter output to one server endpoint.")
tokens_sync_parser = tokens_subparsers.add_parser(
"sync",
help="Synchronize one remote id between git remote URL and tokens.json.",
)
tokens_sync_parser.add_argument("direction", choices=["remote", "store"], help="'remote' reads git remote into store; 'store' writes store into git remote.")
tokens_sync_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.")
tokens_sync_parser.add_argument(
"--repo",
help="Path inside a target git repo. Default: repo containing rvctl.",
)
tokens_sync_parser.add_argument("--url", help="Override target remote URL when writing store -> remote.")
add_store_selection_options(tokens_sync_parser)
tokens_sync_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing store -> remote.")
tokens_sync_parser.add_argument("--dry-run", action="store_true", help="Print the planned sync without writing it.")
tokens_remove_parser = tokens_subparsers.add_parser(
"remove",
aliases=["rm"],
help="Remove a token store record, a Git remote, or both.",
)
tokens_remove_parser.add_argument("target", choices=["store", "remote", "both"], help="Where to remove REMOTE_ID from.")
tokens_remove_parser.add_argument("remote_id", help="Remote id, for example 'r1' or 'r1a'.")
tokens_remove_parser.add_argument(
"--repo",
help="Path inside a target git repo for target remote/both. Default: repo containing rvctl.",
)
tokens_remove_parser.add_argument("--server", help="Server endpoint used to disambiguate store records.")
tokens_remove_parser.add_argument("--dry-run", action="store_true", help="Print the planned removal without writing it.")
tokens_write_parser = tokens_subparsers.add_parser(
"write",
help="Write credentials from tokens.json into a repo remote URL.",
)
add_repo_option(tokens_write_parser)
tokens_write_parser.add_argument("--remote", help="Remote name to update or create.")
tokens_write_parser.add_argument("--url", help="Override target remote URL when creating or updating the remote.")
add_store_selection_options(tokens_write_parser)
tokens_write_parser.add_argument("--replace", action="store_true", help="Replace different credentials in the remote URL.")
tokens_write_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.")
tokens_update_parser = tokens_subparsers.add_parser(
"update",
help="Refresh API metadata for one remote id, or use a legacy --from direction.",
)
tokens_update_parser.add_argument("remote_id", nargs="?", help="Remote id to refresh from API, for example 'r1'.")
tokens_update_parser.add_argument(
"--from",
dest="from_source",
choices=["remotes", "store"],
help="Legacy synchronization direction source: 'remotes' or 'store'.",
)
add_repo_option(tokens_update_parser)
tokens_update_parser.add_argument("--remote", help="Remote name used when --from store.")
tokens_update_parser.add_argument("--url", help="Override target remote URL when --from store.")
add_store_selection_options(tokens_update_parser)
tokens_update_parser.add_argument("--replace", action="store_true", help="Replace different credentials when writing to remotes.")
tokens_update_parser.add_argument("--dry-run", action="store_true", help="Print the planned update without writing it.")
return parser
def main() -> int:
config_path, command_args = extract_config_path(sys.argv[1:])
if not command_args or command_args == ["help"]:
print_main_overview(config_path)
return 0
if command_args in (["tokens"], ["help", "tokens"], ["tokens", "help"]):
print_tokens_overview()
return 0
parser = build_parser()
args = parser.parse_args()
config = load_config(args.config)
if args.command in {"list-series", "list-cards", "series", "tasks", "tmux-container", "submission"}:
ensure_workspace_info(config)
if args.command == "show-config":
print_config(config)
return 0
if args.command == "list-series":
print_series(config)
return 0
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
if args.command == "tmux-container":
run_tmux_container(config, args)
return 0
if args.command == "submission":
print_submission_plan(config, args)
return 0
if args.command == "series":
run_series(config, args)
return 0
if args.command == "tokens":
if args.tokens_command in {"compare", "cmp"}:
run_tokens_compare(config, args)
return 0
if args.tokens_command == "list":
run_tokens_list(config, args)
return 0
if args.tokens_command == "add":
run_tokens_add(config, args)
return 0
if args.tokens_command == "read":
run_tokens_read(config, args)
return 0
if args.tokens_command == "stats":
run_tokens_stats(config, args)
return 0
if args.tokens_command == "sync":
run_tokens_sync(config, args)
return 0
if args.tokens_command in {"remove", "rm"}:
run_tokens_remove(config, args)
return 0
if args.tokens_command == "write":
run_tokens_write(config, args)
return 0
if args.tokens_command == "update":
run_tokens_update(config, args)
return 0
parser.error(f"Unsupported tokens command: {args.tokens_command}")
return 2
parser.error(f"Unsupported command: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())