For developers

This section documents the design of the Making With Code CLI at a technical level. It is intended for software developers who want to understand, modify, or extend the codebase. Students and teachers do not need to read this.

The source code is hosted at github.com/cproctor/making-with-code-courseware.

Running tests

% uv run --group test pytest

Tests live in tests/ and run against real temporary git repositories (via tmp_path and the git_repo fixture in conftest.py) rather than mocking git, since most of what’s worth testing here is exactly how this package handles real git output and edge cases.

Architecture overview

The CLI has four main concerns, each with a corresponding module or package:

  • Account management (mwc_accounts_api.py): HTTP client to the MWC accounts server. Handles authentication and provides the roster of students and their per-student git tokens.

  • Curriculum (curriculum.py): Fetches the course manifest from a curriculum site URL. The manifest is a JSON document listing every course, unit, and module, including each module’s template repo URL.

  • Git backend (git_backend/): Abstracts over the git server. Currently only MWCBackend is implemented, targeting the Gitea instance at git.makingwithcode.org.

  • Settings (settings.py): Reads and writes a TOML config file, defaulting to ~/.config/mwc/settings.toml. The path can be overridden with the MWC_CONFIG environment variable or --config flag, which supports running multiple MWC identities on one machine.

How mwc update wraps git

Each curriculum module is distributed as a git repository. When a student runs mwc update, the CLI:

  1. Calls the accounts server to get the student’s section memberships.

  2. For each section, fetches the curriculum manifest to get the list of modules.

  3. For each module, calls MWCBackend.init_module() (if the repo does not yet exist locally) or MWCBackend.update() (if it does).

init_module() uses the Gitea API to fork the template repo into the student’s account, then clones it with credentials embedded in the HTTPS URL. It also writes a local git config (user.name, user.email, core.editor, commit.template) so students are not prompted for global git identity.

update() delegates to safe_pull(), which is the main focus of this section.

safe_pull()

A bare git pull fails in surprisingly many ways for beginner students who may have left their repos in unusual states. safe_pull() in git_backend/mwc_backend.py wraps the pull in a sequence of pre-flight checks and recovery steps before attempting the pull, and classifies failures into typed exceptions afterward.

Pre-flight recovery

Before pulling, safe_pull() uses helpers from git_wrapper.py to repair common bad states:

  • In-progress merge, rebase, or cherry-pick — Detected by checking for MERGE_HEAD, rebase-merge/, rebase-apply/, or CHERRY_PICK_HEAD in the .git directory. Fixed by running the appropriate --abort command.

  • Detached HEAD — Detected when git branch --show-current returns an empty string. Fixed by stashing any local changes, checking out the default branch (discovered from refs/remotes/origin/HEAD), and popping the stash.

  • Stale index lock — .git/index.lock is removed if present.

Conflict resolution policy

The pull uses git pull -X theirs, which means incoming changes (from the teacher) win in a merge conflict. This is appropriate because teacher pushes are typically feedback annotations, test updates, or bug-fixes to starter code. Before pulling, safe_pull() probes for a genuine conflict with a dry-run merge (git merge --no-commit --no-ff, always aborted afterward): if the student has uncommitted changes that would genuinely conflict, the pull is cancelled outright and the student is told to run mwc submit first, rather than auto-committing on their behalf – one thing at a time is easier to reason about than a merge that silently combines an unwritten commit message with an update. If the conflict is instead with the student’s own committed history, the pull still proceeds (-X theirs resolves it), but the affected files are named explicitly in the output rather than resolved silently.

The -X theirs extended option also handles the case where local and remote histories have diverged (e.g. after a force-push), since it implies a merge strategy and does not require pull.rebase to be configured.

Error classification

If the pull fails after pre-flight and conflict handling, safe_pull() parses the git stderr output and raises a typed exception from the GitError hierarchy (defined in errors.py):

Exception

Condition

GitNetworkError

Host resolution failure, connection timeout, or refused connection.

GitAuthError

Authentication failure or HTTP 403. Student should re-run mwc setup.

GitCorruptError

Corrupt objects or bad refs in the local repo.

GitError (base)

Any other non-zero exit. Git output is echoed in dim style above the friendly message.

These exceptions all inherit from MWCError, so the caller in update/__init__.py can catch them by type and display a beginner-friendly message without a Python traceback.

How mwc submit wraps git

mwc submit (in submit.py) is a wrapper around git add --all && git commit && git push. Running it from anywhere inside a module directory commits all changes and pushes to the MWC git server.

Pre-flight

The same git_wrapper helpers used by safe_pull() run before any git operations: in-progress operations are aborted, detached HEAD is recovered, and the index lock is cleared.

Unpushed commits (S1)

repo_has_changes() checks git status --porcelain, which only reflects the working tree and index. If a previous mwc submit run committed successfully but the push failed (for example, due to a network dropout), the working tree appears clean while commits are stranded locally. has_unpushed_commits() catches this by running git log @{u}..HEAD. When it detects stranded commits, the submit skips straight to the push step.

git commit is run with only stderr captured, not stdout – capturing both would disconnect the student’s editor from the real terminal and can break interactive editing entirely, depending on the editor. Success is never inferred from git’s exit code alone: submit.py compares git rev-parse HEAD before and after, and only treats the commit as real if HEAD actually moved.

Empty commit message (S2)

If the student closes their editor without writing anything, git exits non-zero with "Aborting commit due to empty commit message" in its output. The submit detects this, unstages all files with git restore --staged ., and prompts the student to try again.

Editor fails to launch (S6)

If the student’s configured editor was uninstalled or moved since mwc setup ran, git exits with "unable to start editor". The submit detects this specifically and tells the student to re-run mwc setup to choose a working editor, rather than a generic failure message. configure_git() (git_backend/mwc_backend.py) sets core.editor to whatever the student chose at setup – passed through as-is if it isn’t one of the handful of GUI editors that need an explicit wait flag (EDITOR_COMMANDS) – with nano only as a last-resort default, and re-applies this on every mwc update so existing repos pick up fixes too.

Push rejection (S3)

If git push is rejected as non-fast-forward, it means the teacher pushed to the student’s repo (typically feedback or corrected starter files) since the student last pulled. The submit auto-recovers by running git pull -X ours (student’s committed code wins in any conflict, since this is a submit operation) and retrying the push. As with mwc update, if that resolution overwrote a genuine conflict rather than just combining separate changes, the affected files are named in the output.

Network and authentication errors (S4–S5)

If push fails due to a network error, the student is told explicitly that their commit is safe locally and that re-running mwc submit will send it when connectivity is restored. Authentication failures direct the student to re-run mwc setup.

Success is never assumed

_push() returns True only when a push was actually confirmed to succeed; every failure branch above returns False. submit() only prints its final “Nice job!” message when _push() returns True – previously this was unconditional, so any push failure (network, auth, an unresolvable conflict) still printed a false success message immediately after its own error text.

Error handling design

Two design constraints shape the error handling throughout:

  1. Student work is never silently lost. Any operation that modifies the working tree (stash during detached-HEAD recovery, cancelling an update rather than guessing at a commit on the student’s behalf) either commits the work into git history, restores it afterward, or leaves it untouched and asks the student to handle it explicitly. A stash that can’t be automatically restored is left in place (with the student told how to find it) rather than dropped – dropping it would silently and permanently discard uncommitted work.

  2. Friendly messages, raw output as secondary. When a git operation fails, the raw git stderr is echoed in dim style (using the debug formatter from styles.py) before the friendly message, so a teacher who is helping a student can still see what git actually said.

Key source files

File

Purpose

git_wrapper.py

Stateless helpers for inspecting and repairing repo state. Used by both submit.py and MWCBackend.

git_backend/mwc_backend.py

MWCBackend: clones, configures, and updates student repos. Contains safe_pull() and _raise_pull_error().

git_backend/base_backend.py

Abstract base class. work_dir() and relative_path() helpers used for display.

submit.py

submit Click command and _push() / _preflight_repo() helpers.

errors.py

MWCError hierarchy, including GitError and its subclasses.

update/__init__.py

update Click command. Iterates over modules, calls init_module() or update(), handles GitError with a clean message.

teach/gitea_api/api.py

Low-level Gitea API client used by teacher-side commands (teach update, teach patch). Parallel to MWCBackend but uses per-student tokens from the roster.

assessment.py

Parses, validates, and formats assessment.md’s front matter. No git dependency – pure data. See Assessment data below.

status.py

status Click command (student-facing). Reads assessment.md and git state for every module in the curriculum. Read-only.

teach/status.py / teach/student_repo_functions.py

teach status Click command and its measures. The default assessment measure and the unassessed measure both read assessment.md the same way status.py does.

Assessment data

A student module repo may contain assessment.md, recording how the module was assessed. Nothing in this package writes that file yet – assessment tooling is intentionally decoupled from the git-abstraction and reporting work described above, and is being designed separately. What’s here is the shared contract every reader validates against, so that whatever eventually writes assessment.md (by hand, by script, or a future mwc teach assess) and everything that reads it agree on the same format.

Format

TOML front matter, delimited with +++ (matching the convention MWC’s own site uses for module front matter), followed by an optional prose body – a letter to the student, not touched by assessment.py at all. The front matter is evergreen, not a history: it always describes the module’s current assessment, and re-assessing overwrites it in place. Any record of what changed between assessments and why is prose the teacher writes into the body themselves; assessment.py only ever owns the front matter.

+++
assessment_type = "score"
timestamp = 2026-03-15T09:30:00Z
score = 8
points_possible = 10
+++

Dear student, ...

Every entry has all four keys. assessment_type is "completion" or "score" – score/points_possible have the same shape either way (both required integers, 0 <= score <= points_possible); for "completion", points_possible is always 1, so score is 0 or 1. There’s no separate structured type for rubric-based assessment – the rubric detail lives in the prose body, and the front matter just records a summary score.

timestamp is authoritative for “how recent is this assessment” – deliberately the value in the file, not any git commit metadata, so that determining whether a module has unassessed work only ever depends on data in-band in the repo itself.

API (assessment.py)

  • parse_assessment_file(markdown_text) → Assessment | None. None means not assessed yet (no assessment_type key) – a normal, not an error, state.

  • AssessmentValidationError (an MWCError) is raised for anything present but malformed: wrong types, score outside [0, points_possible], points_possible != 1 for a completion assessment, and so on.

  • format_score(assessment) → compact ASCII rendering for table display: Y/N for completion, 8/10 for score.

  • set_assessment(markdown_text, assessment) → overwrites the front matter in place (creating it if absent), leaving the body untouched.

“Unassessed work”

Both status.py and the teach status measures need the same computation: is there a commit, authored by the student, after the current assessment’s timestamp (or after the beginning of time, if there’s no assessment yet)? This lives in git_wrapper.commits_since() rather than in assessment.py (which stays git-free) or being duplicated per caller. “Authored by the student” is a whitelist match against the exact local git identity configure_git() sets for every student (user.name = their username, user.email = nobody@makingwithcode.org), rather than trying to exclude teacher identities, which aren’t consistently defined anywhere.