Skip to content

The cc Command Line

cc is a small command-line tool for the two things we type most often: deploying a client and running a manage.py command. It lives in cli/ as its own Python package, installed once on the developer machine, and it runs on the host rather than in a container because it drives git, docker, and gh.

Installing it

Install from the main checkout, never from a worktree. An editable install records the path it was installed from, and a worktree is deleted when its work item merges, which would leave cc pointing at a directory that no longer exists.

cd ~/campuscore/campuscore          # the main checkout
uv tool install --editable ./cli

That puts an executable at ~/.local/bin/cc. On macOS /usr/bin/cc is Apple's C compiler and /usr/bin comes first on the PATH, so add an alias to ~/.zshrc rather than reordering the PATH:

alias cc="$HOME/.local/bin/cc"

An alias applies only to the interactive shell. make, configure, and anything else that shells out to cc still finds the compiler, while a cc deploy you type finds this tool.

Editable means the installed command always runs the code in the main checkout, so a git pull there is enough to pick up a change.

The four commands

cc --help prints this list and exits 0.

Command What it does
cc deploy <client> [--yes] Push origin/main to deploy/<client>, which starts that client's deploy workflow
cc migrate [args...] manage.py migrate in this checkout's web container
cc makemigrations [args...] manage.py makemigrations in the same container
cc manage <command> [args...] Any other manage.py command

The three manage.py commands pass every argument through untouched, so cc migrate --fake main_app 0001 and cc manage shell -c "..." reach Django exactly as typed, and cc migrate --help prints Django's help rather than ours.

They run docker compose exec from the root of the checkout you are standing in, which is where compose reads the .env that names the stack. A worktree with its own stack (scripts/stack.sh up) therefore gets its own container, and the main checkout gets the classic one, with no flag to remember. The container command replaces the cc process, so its exit code and its signals are yours: cc manage check in a script fails the script exactly as the container command failed. The -T flag that disables the pseudo-terminal is dropped only when both stdin and stdout are terminals, so cc manage shell is interactive while cc manage dumpdata > f.json writes a file with plain newlines.

What cc deploy guards

A push to deploy/<client> starts a production deploy, so the command reads the whole picture first and refuses before moving any branch when the picture is wrong.

It refuses when origin/deploy/<client> does not exist, and names the deploy branches that do. It refuses when the commit to deploy has a campuscore_app/institutions.toml that maps no institution to the environment, because the workflow's first job would fail on it:

cc deploy: campuscore_app/institutions.toml at 9f2c4e1a7b3d maps no institution to the environment dgu3, so the deploy would fail in the workflow's first job. Add dgu3 to the environments list of its institution in that file, through a pull request to main.

It refuses when local main has commits origin/main does not, because a commit that never went through a pull request must not reach a client. It refuses when the deploy branch has commits that are not on origin/main, because that deploy would not be a fast-forward. It refuses when the prompt is answered anything but yes, and when stdin is not a terminal and --yes was not passed. Every one of those leaves every local branch and the remote deploy branch exactly where they were; the only thing a refusal has touched is the git fetch that refreshed origin/*.

When there is something to deploy the command prints the institution and the connector packages its images carry, then the commits, and asks before pushing:

Institution vsu carries: canvas google_workspace microsoft_365 postgres s3_bucket servicenow vsu_datagw
2 commit(s) to deploy to vsu-troy-pilot (e1e343de05ab):
  e1e343de05ab chore(docs-site): temporarily install the pilot website widget
  4b9fbc295add feat: embeddable website chat widget on top of public access
Deploy 2 commit(s) to vsu-troy-pilot? [y/N]:

The command reads the manifest at the commit it is about to push, with git show <sha>:campuscore_app/institutions.toml, and parses it with the campuscore_app/campus_core/institutions.py of that same commit, the parser its image build and workflow run, so the branch you run it from does not matter. A commit with no manifest prints " predates institutions.toml, so its images carry every connector." in place of the institution line, because the workflow at that commit builds without pruning. A manifest that does not parse is refused, because what would ship cannot be read.

--yes skips the prompt. cc deploy vsu-troy-pilot and cc deploy deploy/vsu-troy-pilot are the same command; the deploy/ prefix is stripped. When the deploy branch already sits on origin/main there is nothing to push, so the command says so, prints gh workflow run deploy-aws.yml -f client=<client> as the way to redeploy that same commit, and exits 0.

Why it pushes origin/main, and why it does not tag

The push sends the exact commit the prompt showed, by sha, to refs/heads/deploy/<client>, never your local main and never the live origin/main ref. A local main that lags would otherwise deploy stale code silently, a fetch that lands while you sit at the prompt cannot change what ships, and because the push carries no force flag, someone else's concurrent push to that branch fails loudly instead of being overwritten.

The deploy workflow already writes the deploy record: deploy-aws.yml pushes an annotated tag deploy-<client>-<run>.<attempt> once the deploy is stable, and a failed deploy deliberately leaves no tag. A tag written by cc before the run finishes would record deploys that never succeeded, so cc deploy writes no tag at all.

After the push the command looks for the workflow run whose head commit is the one just deployed and prints its URL on a Run: line, falling back to the workflow's page if gh cannot answer within thirty seconds; a hung gh is cut off at the same deadline. It does not watch the run; deploy runs always carry benign failure annotations, which are far easier to read in the browser.

The local branches, and the reload that follows

A deploy is not only a push: afterwards local main and local deploy/<client> both point at the deployed commit, so the checkout reflects what is running. A branch that no worktree has checked out is moved with git branch --force, which also creates deploy/<client> the first time. A branch a worktree does hold is advanced with git merge --ff-only inside that worktree.

That merge fails when the worktree has uncommitted changes to a file the fast-forward touches. Because the push has already happened by then, this is a warning, not a failure: the command names the branch and the command to catch it up by hand, and still exits 0. The same holds for a worktree whose directory was deleted without git worktree remove. The deploy is done; only a convenience ref lagged.

Moving the main checkout's main makes its uvicorn --reload restart, which is expected: after a deploy, that checkout is meant to be on the deployed commit.

How it relates to onboard-client.sh deploy

Both push the commit at origin/main to the same branch and differ in what surrounds the push. scripts/onboard-client.sh deploy <env> is the first-deploy tool: it checks that the client's GitHub Environment has every variable and secret, then watches the run by step conclusions for up to ninety minutes. cc deploy assumes a working environment and gets out of the way as soon as the run exists. Use the onboarding script for a client's first deploy, and cc deploy every time after that.

How the package is checked

cli/ is host-side Python, so none of the container-based checks reach it. scripts/cli-checks.sh is the single home of its three commands - ruff check ., ty check src, and pytest tests - each run through uv run --directory cli --frozen so the tools are the versions pinned in cli/uv.lock and nothing in the rest of the repository is in scope.

Three callers share that script. scripts/gate.sh runs it when a file under cli/, or the script itself, differs from main. The cli-checks pre-commit hook runs it with --no-tests on any staged file under cli/, because the tests build real git repositories and take about ten seconds. The cli job in .github/workflows/pr-validation.yml runs it in full on every pull request that touches cli/.

The dev group pins ruff, ty, and pytest to the versions campuscore_app/uv.lock resolves, so the CLI is judged by the same tools as the application; bumping one lock is a reminder to bump the other. scripts/check_comment_hygiene.py applies its Python rules to cli/ as well as to the application.

The tests drive the real commands against a temporary git repository built with git itself - a bare origin, a clone, and a second worktree - and read the refs back with git rev-parse, so a refusal that claims to change nothing is proved to change nothing. docker and gh are stubbed as executables on the PATH, so no test can reach a real container or the real GitHub.

Adding a command

Add a module under cli/src/cc/ that owns the new command, give it a case in the match in cli/src/cc/main.py, and add its line to OVERVIEW in the same file. A command that reads git state before changing it splits the two the way cc/deploy.py does, so the tests can assert every refusal with nothing written. Add the tests under cli/tests/, then run bash scripts/cli-checks.sh.