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 onlyMWCBackendis implemented, targeting the Gitea instance atgit.makingwithcode.org.Settings (
settings.py): Reads and writes a TOML config file, defaulting to~/.config/mwc/settings.toml. The path can be overridden with theMWC_CONFIGenvironment variable or--configflag, 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:
Calls the accounts server to get the student’s section memberships.
For each section, fetches the curriculum manifest to get the list of modules.
For each module, calls
MWCBackend.init_module()(if the repo does not yet exist locally) orMWCBackend.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/, orCHERRY_PICK_HEADin the.gitdirectory. Fixed by running the appropriate--abortcommand.Detached HEAD — Detected when
git branch --show-currentreturns an empty string. Fixed by stashing any local changes, checking out the default branch (discovered fromrefs/remotes/origin/HEAD), and popping the stash.Stale index lock —
.git/index.lockis 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 |
|---|---|
|
Host resolution failure, connection timeout, or refused connection. |
|
Authentication failure or HTTP 403. Student should re-run |
|
Corrupt objects or bad refs in the local repo. |
|
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:
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.
Friendly messages, raw output as secondary. When a git operation fails, the raw git stderr is echoed in dim style (using the
debugformatter fromstyles.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 |
|---|---|
|
Stateless helpers for inspecting and repairing repo state.
Used by both |
|
|
|
Abstract base class. |
|
|
|
|
|
|
|
Low-level Gitea API client used by teacher-side commands
( |
|
Parses, validates, and formats |
|
|
|
|
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.Nonemeans not assessed yet (noassessment_typekey) – a normal, not an error, state.AssessmentValidationError(anMWCError) is raised for anything present but malformed: wrong types,scoreoutside[0, points_possible],points_possible != 1for a completion assessment, and so on.format_score(assessment)→ compact ASCII rendering for table display:Y/Nfor completion,8/10for 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.