sync-assign
sync-assign copies assignments from a teacher-owned Git repository into a
student Git repository. The teacher maps assignment IDs to top-level
directories; a student syncs one assignment at a time.
[!WARNING]
Synchronization is one-way: teacher files are copied into the student
repository. With clean mode enabled, an existing assignment directory is
removed and replaced. Uncommitted student work blocks replacement unless
--force is also used.
- Git available on
PATH
- Access to the configured teacher repository
- A student Git repository; commands must run from its root
uv available on PATH when checking or
grading Python or Jupyter assignments
Release binaries are built for macOS on Apple silicon (darwin/arm64) and
Linux on x86-64 (linux/amd64).
Install
Review install.sh, then run:
curl -fsSL https://raw.githubusercontent.com/ludanortmun/sync-assign/main/install.sh | zsh
The installer selects the matching release, verifies its SHA-256 checksum, and
installs sync-assign in ~/.local/bin without sudo. Set
SYNC_ASSIGN_INSTALL_DIR to install somewhere else.
Install with Go
If Go 1.26.6 or newer is installed, build and install the latest version with:
go install github.com/ludanortmun/sync-assign@latest
Go installs the binary in $GOBIN, or in $(go env GOPATH)/bin when GOBIN
is unset. Ensure that directory is included in PATH.
Teacher repository
Place each assignment in a single top-level directory and add
sync-assign.yml at the repository root:
teacher-repository/
|-- sync-assign.yml
|-- lab-1/
| |-- README.md
| `-- starter.go
`-- project/
`-- README.md
assignments:
lab-1: lab-1
final-project:
path: project
archetype: jupyter
Each key is the assignment ID students pass to the CLI. A scalar value is the
name of one top-level directory and leaves the archetype unset; it can be
synced, but not checked or graded. A mapping supplies path plus an archetype of
python or jupyter. Assignment paths must be single top-level
directory names; absolute and nested paths are rejected. The default teacher
branch is main.
Student setup
From the root of a student Git repository:
sync-assign init-student https://github.com/example/course-assignments.git
If the terminal is interactive, omitting the repository argument prompts for
it. The command creates .sync-assign.yml and refuses to overwrite an existing
file unless --force is supplied. Pass --config=PATH to create it elsewhere.
teacher-repository: https://github.com/example/course-assignments.git
commit: true
clean: false
ephemeral: false
branch: main
The exact supported student configuration keys are:
| Key |
Meaning |
teacher-repository |
Required teacher Git URL or local repository path. |
commit |
Create a local commit after a successful sync. Default: true. |
clean |
Permit replacement of an existing assignment. Default: false. |
teacher-path |
Use this local path for the teacher mirror. |
ephemeral |
Clone into a temporary directory and remove it afterward. Default: false. |
skip-mirror |
Compatibility alias for ephemeral; if both are set, their values must agree. |
branch |
Teacher branch to clone or update. Default: main. |
teacher-path and an enabled ephemeral mode cannot be used together.
Unknown YAML keys are rejected.
Sync an assignment
sync-assign lab-1
The assignment ID is looked up in the teacher's sync-assign.yml. Command-line
options override .sync-assign.yml for that invocation.
Sync flags
| Flag |
Behavior |
--config=PATH |
Read the student configuration from another path. Relative paths are resolved from the student repository root. |
--[no-]commit |
Enable or disable the local commit after syncing. |
--[no-]clean |
Enable or disable replacement of an existing assignment. |
--force |
Allow clean mode to replace an assignment that has uncommitted changes. Requires clean mode. |
--mirror-path=PATH |
Override the local teacher mirror path and disable ephemeral mode. |
--[no-]ephemeral |
Enable or disable a temporary teacher clone; enabling it clears a configured mirror path. |
--branch=BRANCH |
Override the teacher repository branch. |
-m, --message=TEXT |
Set the commit message; the default is Sync assignment <id>. |
--version |
Print version information and exit. |
-h, --help |
Show help. |
Without clean mode, syncing fails if the target assignment directory already
exists. Clean mode replaces that whole directory, but first refuses to proceed
if Git reports tracked or untracked changes within it. --force bypasses only
that dirty-worktree protection and is invalid unless clean mode is enabled.
By default, a successful sync stages changes under the assignment directory
and creates a local commit containing only that directory. Unrelated staged
changes remain in the index, and the sync does not push the commit. Set
commit: false or pass --no-commit to opt out.
init-student flags
sync-assign init-student [<teacher-repo>] [flags]
--[no-]commit, --[no-]clean, --mirror-path, --[no-]ephemeral, and
--branch write the corresponding defaults to .sync-assign.yml, or the path
selected by --config.
--force overwrites an existing student configuration.
Check an assignment before committing
sync-assign check lab-1
check runs the same archetype-specific checker pipeline and produces the same
live, colored report as grade, but evaluates the current working directory
instead of a historical commit. Modified and untracked files are included, so
students can validate their work before committing and pushing.
The command must run from the student Git repository root. It does not create a
commit or modify the working tree.
Check flags
| Flag |
Behavior |
--config=PATH |
Read the student configuration from another path. Relative paths are resolved from the student repository root. |
--mirror-path=PATH |
Override the local teacher mirror path and disable ephemeral mode. |
--[no-]ephemeral |
Enable or disable a temporary teacher clone; enabling it clears a configured mirror path. |
--teacher-branch=BRANCH |
Override the teacher repository branch. |
-h, --help |
Show help. |
Grade an assignment
sync-assign grade lab-1 --due=2026-09-18
Grading selects the latest commit on the requested student branch at or before
the due date and evaluates it in a detached temporary worktree. The branch
defaults to the currently checked-out branch. --due accepts RFC3339 (including
its explicit offset) or YYYY-MM-DD; a date-only value means the end of that
day in the machine's local time zone. When omitted, the current time is used.
Grade flags
| Flag |
Behavior |
--due=DATE |
Optional cutoff in RFC3339 or YYYY-MM-DD form; defaults to the current time. |
--mount=SOURCE:DESTINATION |
Symlink a local directory into the assignment in the grading worktree; repeat for multiple mounts. |
--keep-worktree |
Preserve the grading worktree after the run and print its path. |
--config=PATH |
Read the student configuration from another path. Relative paths are resolved from the student repository root. |
--branch=BRANCH |
Select the student branch; defaults to the current branch. |
--pull |
Update from origin first. The checked-out branch is fetched and fast-forwarded only; another local branch is updated directly by fetch, which refuses non-fast-forward updates. |
--mirror-path=PATH |
Override the local teacher mirror path and disable ephemeral mode. |
--[no-]ephemeral |
Enable or disable a temporary teacher clone; enabling it clears a configured mirror path. |
--teacher-branch=BRANCH |
Override the teacher repository branch. |
-h, --help |
Show help. |
A single teacher-owned configuration can be reused while grading repositories
stored as sibling directories:
students/
|-- .sync-assign.yml
|-- alan/
|-- beth/
|-- carl/
`-- diego/
Run the grade command from each student's Git repository root and point it at
the shared file:
cd students/alan
sync-assign grade lab-1 --due=2026-09-18 --config=../.sync-assign.yml
To make local assets available in the temporary grading worktree, repeat
--mount=SOURCE:DESTINATION for each directory:
sync-assign grade lab-1 --due=2026-09-18 \
--mount=./lab-1/data:data \
--mount=/srv/datasets/models:assets/models
Relative sources resolve from the student repository root; absolute sources
and sources outside the repository are supported. A source must be an existing
directory, and source symlinks are resolved. Destinations resolve from the
assignment directory inside the temporary worktree. Missing parent directories
are created, but an existing destination (including a dangling symlink) causes
an error. Destinations must be relative, cannot target the assignment root or
contain .. or .git components, and cannot have symlink or file parents.
Duplicate and overlapping destinations are rejected.
Mounts are directory symlinks to live assets: their contents reflect the current
local files regardless of --due, and writes through a mount affect its source.
Cleanup removes temporary links and preserves the source directories. Mounts
apply only to that grading invocation; unmounted ignored files remain absent.
Use explicit source and destination paths, without mount-mode suffixes such as
:ro. The final colon separates the paths, and destinations cannot contain
colons. Quote the entire flag value when paths contain spaces.
The grade report lists resolved source directories and assignment-relative
destinations under mounts: alongside the branch, commit, and due date, before
the checks. When no mounts are specified, it prints mounts: none.
Use --keep-worktree to inspect the staged assignment and generated files after
grading. The command prints retained worktree: PATH as soon as the worktree is
created and keeps it even if a check or mount setup fails. Mounted directories
remain live symlinks. The teacher mirror still follows its usual cleanup rules.
Without this flag, the grading worktree is removed as usual.
When finished, run git worktree remove --force PATH from the student repository
and remove the now-empty .sync-assign-grade-* parent directory.
The current teacher mirror is the integrity baseline, even when grading an
older student commit. Assignments without an archetype are not gradable.
Checks run in this order:
python: main Python tests, extra-credit Python tests, then integrity checks
for every teacher-supplied file under a tests directory and every
test_*.py.
jupyter: main and extra-credit Python tests when a tests/
directory exists (otherwise both checks are skipped), main and extra-credit
notebook tests, cleared notebook output/execution counts, an ordered
teacher-cell subsequence check, then the same test-file integrity check.
Python tests run through uv run with pytest; notebook tests use
pytest --nbmake with nbmake. If the assignment has requirements.txt, uv
also loads it with --with-requirements; pytest and nbmake are supplied with
uv's --with options. Files ending in extra_credit.py are excluded from the
main Python test check and run by a separate extra-credit check. Pytest runs in
quiet mode without tracebacks, and failed checks report only pytest's short test
summary. Notebooks matching notebooks/*_extra_credit.ipynb are likewise
excluded from the main notebook test check and run by a separate notebook
extra-credit check.
Each check is printed as soon as it starts and again when it completes.
Running checks use the default terminal color, successful checks are green,
skipped checks are yellow, and failed checks are written to standard error in
red.
Teacher mirror behavior
By default, the teacher repository is kept as a persistent mirror and updated
from its configured branch on every sync. Its path is deterministic:
$XDG_CACHE_HOME/sync-assign/<sha256-of-teacher-repository>
If XDG_CACHE_HOME is unset, the operating system's user cache directory is
used. XDG_CACHE_HOME, when set, must be an absolute path.
Set teacher-path or pass --mirror-path to choose a persistent location.
The path is cloned when absent; an existing path must be a Git worktree.
Set ephemeral: true or pass --ephemeral to clone the teacher repository
into a temporary directory for that invocation. The temporary mirror is
removed when synchronization finishes.