making-with-code-cli 5.2.0__tar.gz → 5.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/PKG-INFO +2 -2
  2. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/README.md +1 -1
  3. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/pyproject.toml +3 -1
  4. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/curriculum.py +1 -1
  5. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/errors.py +12 -0
  6. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/git_backend/base_backend.py +0 -1
  7. making_with_code_cli-5.4.0/src/making_with_code_cli/git_backend/mwc_backend.py +299 -0
  8. making_with_code_cli-5.4.0/src/making_with_code_cli/git_wrapper.py +124 -0
  9. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/mwc_accounts_api.py +20 -0
  10. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/settings.py +2 -2
  11. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/__init__.py +3 -1
  12. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/setup/tasks.py +18 -0
  13. making_with_code_cli-5.4.0/src/making_with_code_cli/submit.py +162 -0
  14. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/__init__.py +2 -0
  15. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/edit.py +1 -1
  16. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/setup.py +2 -0
  17. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/create.py +24 -3
  18. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/__init__.py +31 -0
  19. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/create.py +28 -0
  20. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/delete.py +21 -0
  21. making_with_code_cli-5.4.0/src/making_with_code_cli/teach/workspace/edit.py +34 -0
  22. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/update/__init__.py +7 -2
  23. making_with_code_cli-5.2.0/src/making_with_code_cli/git_backend/mwc_backend.py +0 -160
  24. making_with_code_cli-5.2.0/src/making_with_code_cli/git_wrapper.py +0 -25
  25. making_with_code_cli-5.2.0/src/making_with_code_cli/submit.py +0 -37
  26. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/__init__.py +0 -0
  27. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/cli.py +0 -0
  28. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/decorators.py +0 -0
  29. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/git_backend/__init__.py +0 -0
  30. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/helpers.py +0 -0
  31. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/styles.py +0 -0
  32. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/assess.py +0 -0
  33. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/check/__init__.py +0 -0
  34. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/check/check_module.py +0 -0
  35. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/gitea_api/api.py +0 -0
  36. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/gitea_api/exceptions.py +0 -0
  37. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/log.py +0 -0
  38. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/patch.py +0 -0
  39. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/__init__.py +0 -0
  40. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/create.py +0 -0
  41. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/delete.py +0 -0
  42. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/section/show.py +0 -0
  43. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/status.py +0 -0
  44. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/__init__.py +0 -0
  45. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/edit.py +0 -0
  46. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student/remove.py +0 -0
  47. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student_repo_functions.py +0 -0
  48. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/student_repos.py +0 -0
  49. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/teach/update.py +0 -0
  50. {making_with_code_cli-5.2.0 → making_with_code_cli-5.4.0}/src/making_with_code_cli/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: making-with-code-cli
3
- Version: 5.2.0
3
+ Version: 5.4.0
4
4
  Summary: Courseware for Making With Code
5
5
  Author: Chris Proctor
6
6
  Author-email: Chris Proctor <chris@chrisproctor.net>
@@ -42,7 +42,7 @@ Making With Code, there is a separate bootstrapping process you should follow.
42
42
  Please consult your curriculum website (or ask your teacher) for detailed instructions
43
43
  on how and why to use this tool.
44
44
 
45
- pip3 install making-with-code-cli
45
+ uv tool install making-with-code-cli
46
46
 
47
47
  Once installed, you can set up your computer using the `mwc setup` command. Run
48
48
  `mwc --help` to see all the commands available.
@@ -17,7 +17,7 @@ Making With Code, there is a separate bootstrapping process you should follow.
17
17
  Please consult your curriculum website (or ask your teacher) for detailed instructions
18
18
  on how and why to use this tool.
19
19
 
20
- pip3 install making-with-code-cli
20
+ uv tool install making-with-code-cli
21
21
 
22
22
  Once installed, you can set up your computer using the `mwc setup` command. Run
23
23
  `mwc --help` to see all the commands available.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "making-with-code-cli"
3
- version = "5.2.0"
3
+ version = "5.4.0"
4
4
  description = "Courseware for Making With Code"
5
5
  authors = [{ name = "Chris Proctor", email = "chris@chrisproctor.net" }]
6
6
  requires-python = ">=3.11,<4.0"
@@ -41,3 +41,5 @@ docs = [
41
41
  "sphinx>=8.2.3,<9",
42
42
  "sphinx-rtd-theme>=3.0.2,<4",
43
43
  ]
44
+ [tool.uv]
45
+ check-url = "https://pypi.org/simple/"
@@ -11,7 +11,7 @@ def get_curriculum(mwc_site_url, course_name=None):
11
11
  """Fetches curriculum metadata from the site url specified in settings.
12
12
  Returns the curriculum metadata for course_name.
13
13
  """
14
- url = mwc_site_url + "/manifest"
14
+ url = mwc_site_url.rstrip("/") + "/manifest"
15
15
  try:
16
16
  response = requests.get(url, timeout=15)
17
17
  except requests.exceptions.RequestException as e:
@@ -26,3 +26,15 @@ class SoftwareInstallationError(MWCError):
26
26
 
27
27
  class NoCurriculaAvailable(MWCError):
28
28
  pass
29
+
30
+ class GitError(MWCError):
31
+ pass
32
+
33
+ class GitNetworkError(GitError):
34
+ pass
35
+
36
+ class GitAuthError(GitError):
37
+ pass
38
+
39
+ class GitCorruptError(GitError):
40
+ pass
@@ -2,7 +2,6 @@ from pathlib import Path
2
2
  from subprocess import run, CalledProcessError
3
3
  import click
4
4
  from making_with_code_cli.helpers import cd
5
- from making_with_code_cli.setup.tasks import WORK_DIR_PERMISSIONS
6
5
  from making_with_code_cli.styles import (
7
6
  address,
8
7
  info,
@@ -0,0 +1,299 @@
1
+ from .base_backend import GitBackend
2
+ from subprocess import run, CalledProcessError
3
+ import traceback
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+ import click
7
+ import json
8
+ import os
9
+ import requests
10
+ from making_with_code_cli.helpers import cd
11
+ from making_with_code_cli.git_wrapper import (
12
+ abort_in_progress_operation,
13
+ recover_detached_head,
14
+ clear_stale_index_lock,
15
+ _get_default_branch,
16
+ )
17
+ from making_with_code_cli.styles import (
18
+ address,
19
+ confirm,
20
+ debug,
21
+ error,
22
+ info,
23
+ )
24
+ from making_with_code_cli.errors import (
25
+ MissingSetting,
26
+ GitServerNotAvailable,
27
+ GitError,
28
+ GitNetworkError,
29
+ GitAuthError,
30
+ GitCorruptError,
31
+ )
32
+
33
+ EDITOR_COMMANDS = {
34
+ "atom": "atom --wait",
35
+ "code": "code --wait",
36
+ "subl": "subl -n -w",
37
+ "mate": "mate -w",
38
+ "vim": "vim",
39
+ "emacs": "emacs",
40
+ }
41
+
42
+ # Phrases in git stderr that identify each failure mode
43
+ _NETWORK_PHRASES = [
44
+ "Could not resolve host",
45
+ "Connection timed out",
46
+ "unable to connect",
47
+ "Failed to connect",
48
+ "network is unreachable",
49
+ "Connection refused",
50
+ ]
51
+ _AUTH_PHRASES = [
52
+ "Authentication failed",
53
+ "403",
54
+ "invalid credentials",
55
+ "could not read Username",
56
+ "terminal prompts disabled",
57
+ "Repository not found",
58
+ ]
59
+ _CORRUPT_PHRASES = [
60
+ "corrupt",
61
+ "bad object",
62
+ "loose object",
63
+ "fatal: bad",
64
+ "object file is empty",
65
+ ]
66
+ _NO_UPSTREAM_PHRASES = [
67
+ "no tracking information",
68
+ "has no upstream",
69
+ "There is no tracking information",
70
+ ]
71
+
72
+
73
+ class MWCBackend(GitBackend):
74
+ """A Github backend. Students own their own repos and grant teachers access via token."""
75
+
76
+ MWC_GIT_PROTOCOL = "https"
77
+ MWC_GIT_SERVER = "git.makingwithcode.org"
78
+ COMMIT_TEMPLATE = ".commit_template"
79
+
80
+ @property
81
+ def git_server(self):
82
+ """Returns the git server hostname, respecting git_server_url setting if set."""
83
+ url = self.settings.get('git_server_url')
84
+ if url:
85
+ parsed = urlparse(url)
86
+ return parsed.netloc
87
+ return self.MWC_GIT_SERVER
88
+
89
+ def init_module(self, module, modpath):
90
+ """Creates the named repo from a template."""
91
+ self.check_settings()
92
+ server, repo_owner, repo_name = self.parse_repo_url(module["repo_url"])
93
+
94
+ if modpath.exists():
95
+ self.relocate_existing_directory(modpath)
96
+ if not self.user_has_repo(repo_name):
97
+ self.create_from_template(repo_owner, repo_name)
98
+ with cd(modpath.parent):
99
+ self.clone_repo(repo_name)
100
+ self.configure_git(modpath)
101
+ if (modpath / self.COMMIT_TEMPLATE).exists():
102
+ run(["git", "config", "--local", "commit.template", self.COMMIT_TEMPLATE],
103
+ check=True, cwd=modpath)
104
+ if self.settings.get('manage_python_environment', True):
105
+ run("uv venv", shell=True, check=True, cwd=modpath, capture_output=True)
106
+ self.init_direnv(modpath)
107
+
108
+ def configure_git(self, modpath):
109
+ """Configures git user settings locally for the repo."""
110
+ name = self.settings['mwc_username']
111
+ run(["git", "config", "--local", "user.name", name], check=True, cwd=modpath)
112
+ run(["git", "config", "--local", "user.email", "nobody@makingwithcode.org"],
113
+ check=True, cwd=modpath)
114
+ editor = self.settings.get('editor')
115
+ if editor in EDITOR_COMMANDS:
116
+ run(["git", "config", "--local", "core.editor", EDITOR_COMMANDS[editor]],
117
+ check=True, cwd=modpath)
118
+
119
+ def update(self, module, modpath, install=True):
120
+ if (modpath / ".git").is_dir():
121
+ relpath = self.relative_path(modpath)
122
+ click.echo(address(f"Checking {relpath} for updates.", preformatted=True))
123
+ pull_output = self.safe_pull(modpath)
124
+ if pull_output:
125
+ click.echo(info(pull_output))
126
+ if install and (modpath / "pyproject.toml").exists() and self.settings.get('manage_python_environment', True):
127
+ result = run("uv sync", shell=True, capture_output=True, text=True, cwd=modpath)
128
+ if result.returncode != 0:
129
+ click.echo(debug("Error running uv sync:"))
130
+ click.echo(debug(result.stderr, preformatted=True))
131
+ click.echo(error(f"There was a problem updating {relpath}. Ask a teacher."))
132
+ else:
133
+ self.init_direnv(modpath)
134
+
135
+ def safe_pull(self, modpath):
136
+ """Pulls from origin with robust recovery for common git error states.
137
+ Returns the git output string on success. Raises GitError subclasses on
138
+ unrecoverable failures.
139
+ """
140
+ # Fix bad repo states before attempting the pull
141
+ aborted = abort_in_progress_operation(modpath)
142
+ if aborted:
143
+ click.echo(confirm(
144
+ f"Cleared an incomplete git {aborted} — this shouldn't affect your work."
145
+ ))
146
+
147
+ branch = recover_detached_head(modpath)
148
+ if branch:
149
+ click.echo(confirm(
150
+ f"Your repo was not on any branch. Switched back to '{branch}'."
151
+ ))
152
+
153
+ clear_stale_index_lock(modpath)
154
+
155
+ # U1: uncommitted changes that would be overwritten — auto-commit first
156
+ status = run(
157
+ "git status --porcelain",
158
+ shell=True, capture_output=True, text=True, cwd=modpath
159
+ )
160
+ if status.stdout.strip():
161
+ probe = run(
162
+ "git fetch origin",
163
+ shell=True, capture_output=True, text=True, cwd=modpath
164
+ )
165
+ merge_check = run(
166
+ "git merge --no-commit --no-ff FETCH_HEAD",
167
+ shell=True, capture_output=True, text=True, cwd=modpath
168
+ )
169
+ # Always abort the probe merge so we start clean
170
+ run("git merge --abort", shell=True, capture_output=True, cwd=modpath)
171
+ if "CONFLICT" in merge_check.stdout or "CONFLICT" in merge_check.stderr:
172
+ run(["git", "add", "--all"], capture_output=True, cwd=modpath)
173
+ run(
174
+ ["git", "commit", "-m", "[auto-saved before update]"],
175
+ capture_output=True, cwd=modpath
176
+ )
177
+ click.echo(confirm(
178
+ "Your unsaved changes were auto-saved in git history "
179
+ "before applying the update."
180
+ ))
181
+
182
+ result = self._run_pull(modpath)
183
+
184
+ if result.returncode == 0:
185
+ return result.stdout
186
+
187
+ combined = result.stderr + result.stdout
188
+
189
+ # U8: no upstream tracking branch — set it and retry
190
+ if any(p in combined for p in _NO_UPSTREAM_PHRASES):
191
+ default_branch = _get_default_branch(modpath)
192
+ run(
193
+ f"git branch --set-upstream-to=origin/{default_branch} {default_branch}",
194
+ shell=True, capture_output=True, cwd=modpath
195
+ )
196
+ result = self._run_pull(modpath)
197
+ if result.returncode == 0:
198
+ return result.stdout
199
+ combined = result.stderr + result.stdout
200
+
201
+ click.echo(debug(combined.strip(), preformatted=True))
202
+ self._raise_pull_error(combined)
203
+
204
+ def _run_pull(self, modpath):
205
+ # -X theirs resolves merge conflicts in favor of incoming (teacher) changes,
206
+ # and also handles diverged-history cases (U2, U3) without extra configuration.
207
+ return run(
208
+ "git pull -X theirs",
209
+ shell=True, capture_output=True, text=True, cwd=modpath
210
+ )
211
+
212
+ def _raise_pull_error(self, stderr):
213
+ if any(p in stderr for p in _NETWORK_PHRASES):
214
+ raise GitNetworkError(
215
+ "Could not connect to the MWC server. "
216
+ "Check your internet connection and try again."
217
+ )
218
+ if any(p in stderr for p in _AUTH_PHRASES):
219
+ raise GitAuthError(
220
+ "Your login credentials are no longer valid. "
221
+ "Run `mwc setup` to refresh them, then run `mwc update` again."
222
+ )
223
+ if any(p in stderr for p in _CORRUPT_PHRASES):
224
+ raise GitCorruptError(
225
+ "Your local copy of this module appears to be damaged. "
226
+ "Ask your teacher for help."
227
+ )
228
+ raise GitError(
229
+ "Could not update this module. "
230
+ "The details are shown above. If you're not sure what to do, ask your teacher."
231
+ )
232
+
233
+ def init_direnv(self, modpath):
234
+ if not (modpath / ".envrc").exists():
235
+ (modpath / ".envrc").write_text("source .venv/bin/activate")
236
+ run("direnv allow", shell=True, check=True, cwd=modpath)
237
+
238
+ def user_has_repo(self, repo_name, username=None):
239
+ """Checks to see whether a user already has the named repo."""
240
+ username = username or self.settings['mwc_username']
241
+ url = f"/repos/{username}/{repo_name}"
242
+ response = self.authenticated_mwc_request('get', url)
243
+ return response.ok
244
+
245
+ def create_from_template(self, template_owner, template_name):
246
+ url = f"/repos/{template_owner}/{template_name}/generate"
247
+ data = {
248
+ "name": template_name,
249
+ "owner": self.settings['mwc_username'],
250
+ "git_content": True
251
+ }
252
+ response = self.authenticated_mwc_request('post', url, data=data)
253
+ return response
254
+
255
+ def clone_repo(self, repo_name):
256
+ user = self.settings['mwc_username']
257
+ auth = user + ':' + self.settings['mwc_git_token']
258
+ url = f"{self.MWC_GIT_PROTOCOL}://{auth}@{self.git_server}/{user}/{repo_name}.git"
259
+ run(["git", "clone", url], check=True, capture_output=True)
260
+
261
+ def check_settings(self):
262
+ if "mwc_username" not in self.settings:
263
+ raise MissingSetting('mwc_username')
264
+ if "mwc_git_token" not in self.settings:
265
+ raise MissingSetting('mwc_git_token')
266
+
267
+ def parse_repo_url(self, url):
268
+ """Parses a MWC url like "https://git.makingwithcode.org/mwc/lab_pipes.git"
269
+ Returns ("https://git.makingwithcode.org", "mwc", "lab_pipes")
270
+ """
271
+ result = urlparse(url)
272
+ server = f"{result.scheme}://{result.netloc}"
273
+ path, suffix = result.path.split('.')
274
+ repo_owner, repo_name = path.strip('/').split('/')
275
+ return (server, repo_owner, repo_name)
276
+
277
+ def authenticated_mwc_request(self, method_name, urlpath, data=None, params=None):
278
+ server = self.MWC_GIT_PROTOCOL + '://' + self.git_server
279
+ args = {
280
+ 'url': server + "/api/v1" + urlpath,
281
+ 'auth': (self.settings['mwc_username'], self.settings['mwc_git_token']),
282
+ }
283
+ if data:
284
+ args['data'] = data
285
+ if params:
286
+ args['params'] = params
287
+ method = getattr(requests, method_name)
288
+ response = method(**args)
289
+ if response.status_code >= 500 or response.status_code == 403:
290
+ raise GitServerNotAvailable(server)
291
+ return response
292
+
293
+ def relocate_existing_directory(self, path):
294
+ """Moves an existing directory out of the way."""
295
+ new_path = path.parent / path.name + '_old'
296
+ while new_path.exists():
297
+ new_path = new_path.parent / new_path.name + '_old'
298
+ click.echo(confirm(f"Moving existing directory {path} to {new_path}."))
299
+ os.rename(path, new_path)
@@ -0,0 +1,124 @@
1
+ from pathlib import Path
2
+ from subprocess import run, CalledProcessError
3
+ from making_with_code_cli.helpers import cd
4
+
5
+
6
+ def in_repo():
7
+ """Checks whether currently in repo"""
8
+ try:
9
+ run("git status", shell=True, capture_output=True, check=True)
10
+ return True
11
+ except CalledProcessError:
12
+ return False
13
+
14
+
15
+ def repo_has_changes():
16
+ return len(changed_files()) > 0
17
+
18
+
19
+ def changed_files():
20
+ "Returns a list of (status, filename) tuples for changed files"
21
+ cmd = "git status --porcelain"
22
+ result = run(cmd, shell=True, capture_output=True, text=True).stdout
23
+ files = []
24
+ if result:
25
+ for line in result.split('\n'):
26
+ files.append((line[:2], line[3:]))
27
+ return files
28
+ else:
29
+ return []
30
+
31
+
32
+ def has_unpushed_commits(cwd=None):
33
+ """Returns True if there are committed but unpushed local changes."""
34
+ result = run(
35
+ "git log @{u}..HEAD --oneline",
36
+ shell=True, capture_output=True, text=True, cwd=cwd
37
+ )
38
+ if result.returncode != 0:
39
+ return False
40
+ return bool(result.stdout.strip())
41
+
42
+
43
+ def get_repo_state(cwd=None):
44
+ """Returns 'merge', 'rebase', 'cherry-pick', 'detached', or 'clean'."""
45
+ git_dir = _git_dir(cwd)
46
+ if (git_dir / "MERGE_HEAD").exists():
47
+ return "merge"
48
+ if (git_dir / "rebase-merge").exists() or (git_dir / "rebase-apply").exists():
49
+ return "rebase"
50
+ if (git_dir / "CHERRY_PICK_HEAD").exists():
51
+ return "cherry-pick"
52
+ result = run(
53
+ "git branch --show-current",
54
+ shell=True, capture_output=True, text=True, cwd=cwd
55
+ )
56
+ if result.returncode == 0 and not result.stdout.strip():
57
+ return "detached"
58
+ return "clean"
59
+
60
+
61
+ def abort_in_progress_operation(cwd=None):
62
+ """Aborts any in-progress merge, rebase, or cherry-pick.
63
+ Returns the operation name that was aborted, or None if nothing to abort.
64
+ """
65
+ state = get_repo_state(cwd)
66
+ commands = {
67
+ "merge": "git merge --abort",
68
+ "rebase": "git rebase --abort",
69
+ "cherry-pick": "git cherry-pick --abort",
70
+ }
71
+ if state in commands:
72
+ run(commands[state], shell=True, capture_output=True, cwd=cwd)
73
+ return state
74
+ return None
75
+
76
+
77
+ def recover_detached_head(cwd=None):
78
+ """If in detached HEAD, checks out the default branch and returns its name.
79
+ Returns None if no action was needed.
80
+ """
81
+ if get_repo_state(cwd) != "detached":
82
+ return None
83
+ branch = _get_default_branch(cwd)
84
+ # Stash any uncommitted work so the checkout doesn't fail
85
+ stash_result = run(
86
+ "git stash", shell=True, capture_output=True, text=True, cwd=cwd
87
+ )
88
+ had_stash = "No local changes to save" not in stash_result.stdout
89
+ run(["git", "checkout", branch], capture_output=True, cwd=cwd)
90
+ if had_stash:
91
+ pop_result = run(
92
+ "git stash pop", shell=True, capture_output=True, text=True, cwd=cwd
93
+ )
94
+ # If pop conflicts (extreme edge case), drop the stash
95
+ if pop_result.returncode != 0:
96
+ run("git stash drop", shell=True, capture_output=True, cwd=cwd)
97
+ return branch
98
+
99
+
100
+ def clear_stale_index_lock(cwd=None):
101
+ """Removes .git/index.lock if present. Returns True if a lock was cleared."""
102
+ lock = _git_dir(cwd) / "index.lock"
103
+ if lock.exists():
104
+ lock.unlink()
105
+ return True
106
+ return False
107
+
108
+
109
+ def _get_default_branch(cwd=None):
110
+ """Returns the remote default branch name, falling back to 'main'."""
111
+ result = run(
112
+ "git symbolic-ref refs/remotes/origin/HEAD",
113
+ shell=True, capture_output=True, text=True, cwd=cwd
114
+ )
115
+ if result.returncode == 0:
116
+ return result.stdout.strip().replace("refs/remotes/origin/", "")
117
+ return "main"
118
+
119
+
120
+ def _git_dir(cwd=None):
121
+ """Returns the Path to the .git directory for the given working dir."""
122
+ if cwd is None:
123
+ return Path(".git")
124
+ return Path(cwd) / ".git"
@@ -45,6 +45,26 @@ class MWCAccountsAPI:
45
45
  response = self.put("/students", data=params, token=token)
46
46
  return self.handle_response(response)
47
47
 
48
+ def sync_workspace(self, token, workspace_id):
49
+ response = self.post(f"/workspaces/{workspace_id}/sync", token=token)
50
+ return self.handle_response(response)
51
+
52
+ def list_workspaces(self, token):
53
+ response = self.get("/workspaces", token=token)
54
+ return self.handle_response(response)
55
+
56
+ def create_workspace(self, token, params):
57
+ response = self.post("/workspaces", data=params, token=token)
58
+ return self.handle_response(response)
59
+
60
+ def update_workspace(self, token, workspace_id, params):
61
+ response = self.put(f"/workspaces/{workspace_id}", data=params, token=token)
62
+ return self.handle_response(response)
63
+
64
+ def delete_workspace(self, token, workspace_id):
65
+ response = self.delete(f"/workspaces/{workspace_id}", token=token)
66
+ return self.handle_response(response)
67
+
48
68
  def create_section(self, token, params):
49
69
  response = self.post("/sections", data=params, token=token)
50
70
  return self.handle_response(response)
@@ -5,7 +5,7 @@ import os
5
5
 
6
6
  def get_settings_path(settings_path=None):
7
7
  """Determines the settings path, based on what is passed and environment.
8
- "The default location for the MWC configuration file is ~/.mwc.
8
+ "The default location for the MWC configuration file is ~/.config/mwc/settings.toml.
9
9
  This can be specified (e.g. to support parallel installations) using
10
10
  the MWC_CONFIG environment variable or the --config flag.
11
11
  """
@@ -14,7 +14,7 @@ def get_settings_path(settings_path=None):
14
14
  elif "MWC_CONFIG" in os.environ:
15
15
  return Path(os.environ["MWC_CONFIG"])
16
16
  else:
17
- xdg_config_home = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
17
+ xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
18
18
  return xdg_config_home / "mwc" / "settings.toml"
19
19
 
20
20
  def read_settings(settings_path=None):
@@ -4,7 +4,6 @@ from pprint import pformat
4
4
  import traceback
5
5
  from making_with_code_cli.mwc_accounts_api import MWCAccountsAPI
6
6
  from making_with_code_cli.git_backend import get_backend
7
- from making_with_code_cli.update import update
8
7
  from making_with_code_cli.decorators import handle_mwc_errors
9
8
  from making_with_code_cli.settings import (
10
9
  get_settings_path,
@@ -86,6 +85,8 @@ def setup(ctx, config, debug, mwc_accounts_url):
86
85
  token = prompt_mwc_password(settings['mwc_username'], api)
87
86
  settings['mwc_accounts_token'] = token
88
87
  settings['mwc_git_token'] = status['git_token']
88
+ if status.get('git_server_url'):
89
+ settings['git_server_url'] = status['git_server_url']
89
90
  settings['work_dir'] = str(choose_work_dir(settings.get("work_dir")).resolve())
90
91
  settings['editor'] = choose_editor(settings.get('editor', 'code'))
91
92
  if debug:
@@ -128,4 +129,5 @@ def setup(ctx, config, debug, mwc_accounts_url):
128
129
  for task in errors:
129
130
  click.echo(error(f"- {task.description}"))
130
131
  else:
132
+ from making_with_code_cli.update import update
131
133
  ctx.invoke(update, config=config)
@@ -403,10 +403,28 @@ class InstallVSCode(InstallPackage):
403
403
  brew_name = "visual-studio-code"
404
404
  brew_cask = True
405
405
 
406
+ def is_complete(self):
407
+ if Platform.detect() == Platform.MAC:
408
+ app_locations = [
409
+ Path("/Applications/Visual Studio Code.app"),
410
+ Path.home() / "Applications/Visual Studio Code.app",
411
+ ]
412
+ return self.executable_on_path("code") or any(p.exists() for p in app_locations)
413
+ return self.executable_on_path("code")
414
+
406
415
  def run_task(self):
407
416
  if Platform.detect() in (Platform.UBUNTU, Platform.WSL):
408
417
  run("sudo snap install --classic code", shell=True, check=True)
409
418
  else:
419
+ # If brew has a stale record of VS Code but the app is missing,
420
+ # the upgrade will fail. Zap the cask first to clear brew's state.
421
+ stale = run(
422
+ "brew list --cask visual-studio-code",
423
+ shell=True, capture_output=True
424
+ ).returncode == 0
425
+ if stale:
426
+ run("brew uninstall --cask --zap --force visual-studio-code",
427
+ shell=True, check=True)
410
428
  super().run_task()
411
429
 
412
430
  class InstallImageMagick(InstallPackage):