gitpro 0.3.1__py3-none-any.whl

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.
gitpro/devcontainer.py ADDED
@@ -0,0 +1,442 @@
1
+ """`gitpro devcontainer`: the devcontainer.json `initializeCommand` that runs `gitpro pin` on the host.
2
+
3
+ Containers do not see the host's gitpro files, so the identity is lost there unless the repo is pinned.
4
+ initializeCommand runs on the host before every container start; the line below pins the repo when the
5
+ host has gitpro and does nothing otherwise. devcontainer.json is a tracked team file (DECISIONS Q5/Q8):
6
+ gitpro prints the line, and edits the file only with --write (preview, confirm, backup, undo).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import List, Optional
19
+
20
+ from . import config, jsonfile, jsonout, resolve
21
+ from .args import capability, fail, guarded
22
+ from .config import ConfigError
23
+
24
+ KEY = "initializeCommand"
25
+ CANDIDATES = (".devcontainer/devcontainer.json", ".devcontainer.json")
26
+ CONTAINER_ENV = ("REMOTE_CONTAINERS", "CODESPACES", "DEVCONTAINER") # same list as vscode/src/identityBar.ts
27
+ DOCKERENV = Path("/.dockerenv")
28
+
29
+ # One string, because a string runs in the host's shell: `/bin/sh -c` on Linux, macOS and WSL, `cmd.exe /c`
30
+ # on Windows (an array would need `sh` on every host). Line 1 is true in sh; in cmd.exe `command` is not a
31
+ # command, so cmd exits 0 there (and cmd stops at the first line feed anyway). The workspace path is read
32
+ # from a quoted here-document, so no character in it is ever parsed as shell code; pin and `exit 0` sit on
33
+ # the read line itself, so even a path holding a newline and a GITPRO_PATH line ends the script before the
34
+ # rest of the path could run as a command.
35
+ SCRIPT_LINES = (
36
+ "command true || exit 0",
37
+ "# runs 'gitpro pin' on hosts that have gitpro, else nothing (line 1 stops Windows cmd)",
38
+ "command -v gitpro >/dev/null 2>&1 || exit 0",
39
+ """IFS= read -r dir <<'GITPRO_PATH'; gitpro pin -y "$dir" || true; exit 0""",
40
+ "${localWorkspaceFolder}",
41
+ "GITPRO_PATH",
42
+ )
43
+ LINE = "\n".join(SCRIPT_LINES)
44
+
45
+ NOTE = ("initializeCommand runs on the host before the container starts. On Linux, macOS and WSL this line "
46
+ "runs gitpro pin when gitpro is installed and does nothing otherwise; on Windows (cmd.exe) its first "
47
+ "line exits 0, so a colleague without gitpro is not affected and the container still starts (the "
48
+ "build log then shows \"'command' is not recognized as an internal or external command\", which is "
49
+ "harmless).")
50
+ MERGED = ("your command now runs alongside gitpro pin (in parallel); its output appears when it finishes")
51
+ TEAM = ("the line pins only on hosts that have gitpro: every teammate who opens this container needs gitpro "
52
+ "on their host to get the right identity (without it, the line does nothing)")
53
+ _PIN_RE = re.compile(r"gitpro\s+pin\b[^\n;|&]*?\s(?:-y|--yes)(?=\s|[;|&]|$)")
54
+
55
+
56
+ # ---------------------------------------------------------------- pure parts
57
+
58
+ def _tilde(path: Path) -> str:
59
+ home = str(Path.home())
60
+ s = str(path)
61
+ return "~" + s[len(home):] if s == home or s.startswith(home + "/") else s
62
+
63
+
64
+ def locate(repo: Path) -> Optional[Path]:
65
+ """DIR/.devcontainer/devcontainer.json, else DIR/.devcontainer.json, else None."""
66
+ for name in CANDIDATES:
67
+ path = Path(repo) / name
68
+ if path.is_file():
69
+ return path
70
+ return None
71
+
72
+
73
+ def has_pin(value: object) -> bool:
74
+ """value (a string, an argv list or the object form) already runs `gitpro pin`."""
75
+ if isinstance(value, str):
76
+ return bool(_PIN_RE.search(value))
77
+ if isinstance(value, list):
78
+ return has_pin(" ".join(str(v) for v in value))
79
+ if isinstance(value, dict):
80
+ return any(has_pin(v) for v in value.values())
81
+ return False
82
+
83
+
84
+ def _empty(value: object) -> bool:
85
+ return value is None or value == [] or (isinstance(value, str) and not value.strip())
86
+
87
+
88
+ def propose(existing: object) -> object:
89
+ """The initializeCommand to propose given the current one (None: absent). An existing command is kept
90
+ verbatim, never evaluated: a string or array moves into the object form next to ours."""
91
+ if isinstance(existing, str) and existing == LINE.replace('gitpro pin -y "$dir"', 'gitpro pin "$dir"'):
92
+ return LINE
93
+ if has_pin(existing):
94
+ return existing
95
+ if _empty(existing):
96
+ return LINE
97
+ if isinstance(existing, dict):
98
+ key = next((k for k in ("gitpro", "gitpro-pin") if k not in existing), None)
99
+ if key is None:
100
+ raise ConfigError(f"{KEY} already has keys \"gitpro\" and \"gitpro-pin\" that do not run gitpro "
101
+ f"pin; gitpro will not replace them: add the line by hand under another key: "
102
+ f"{json.dumps(LINE, ensure_ascii=False)}")
103
+ return {key: LINE, **existing}
104
+ if isinstance(existing, str):
105
+ word = existing.split()[0]
106
+ return {"gitpro": LINE, ("cmd" if word == "gitpro" else word): existing}
107
+ if isinstance(existing, list):
108
+ return {"gitpro": LINE, "cmd": existing}
109
+ raise ConfigError(f"{KEY} is {json.dumps(existing)}, not a string, array or object; "
110
+ f"add the key by hand: {snippet(LINE)}")
111
+
112
+
113
+ def snippet(value: object) -> str:
114
+ return f'"{KEY}": {json.dumps(value, ensure_ascii=False)}'
115
+
116
+
117
+ def in_container(env: Optional[dict] = None) -> bool:
118
+ """The one detection (15's extension carries the same list): any of CONTAINER_ENV set, or /.dockerenv."""
119
+ env = os.environ if env is None else env
120
+ return any(env.get(k) for k in CONTAINER_ENV) or DOCKERENV.exists()
121
+
122
+
123
+ def _container_reason() -> str:
124
+ names = [k for k in CONTAINER_ENV if os.environ.get(k)]
125
+ return ", ".join(names) or str(DOCKERENV)
126
+
127
+
128
+ # ---------------------------------------------------------------- reading the file
129
+
130
+ def tracked(repo: Path, path: Path) -> bool:
131
+ r = subprocess.run(["git", "-C", str(repo), "ls-files", "--error-unmatch", "--", str(path)],
132
+ capture_output=True, env=resolve._child_env(), stdin=subprocess.DEVNULL)
133
+ return r.returncode == 0
134
+
135
+
136
+ @dataclass
137
+ class Found:
138
+ repo: Path
139
+ file: Path
140
+ existing: object
141
+ proposed: object
142
+ has_pin: bool
143
+ has_comments: bool
144
+ tracked: bool
145
+
146
+ @property
147
+ def merged(self) -> bool:
148
+ """The user's string or array command moves into the object form, next to ours (run in parallel)."""
149
+ return not self.has_pin and isinstance(self.existing, (str, list)) and not _empty(self.existing)
150
+
151
+ def to_json(self) -> dict:
152
+ return {"file": str(self.file), "tracked": self.tracked, "has_pin": self.has_pin,
153
+ "existing_command": self.existing, "proposed": self.proposed, "merged": self.merged,
154
+ "has_comments": self.has_comments, "written": False}
155
+
156
+
157
+ def _read(path: Path) -> tuple:
158
+ """(object, text); ConfigError naming the line to add by hand when the file cannot be used."""
159
+ hand = f"; add the key by hand: {snippet(LINE)}"
160
+ try:
161
+ obj, text = jsonfile.read(path, jsonc=True)
162
+ except ConfigError as e:
163
+ raise ConfigError(f"{e}{hand}") from None
164
+ if not isinstance(obj, dict):
165
+ raise ConfigError(f"{path}: cannot parse (the top level is not an object){hand}")
166
+ return obj, text
167
+
168
+
169
+ def inspect(repo: Path, path: Path) -> Found:
170
+ obj, text = _read(path)
171
+ existing = obj.get(KEY)
172
+ return Found(repo, path, existing, propose(existing), has_pin(existing), jsonfile.strip_jsonc(text)[1],
173
+ tracked(repo, path))
174
+
175
+
176
+ def write(found: Found, *, confirm) -> jsonfile.Result:
177
+ """Add or merge initializeCommand through jsonfile.edit_json: preview to confirm(diff), backup, undo, write."""
178
+ def mutate(obj):
179
+ obj[KEY] = propose(obj.get(KEY))
180
+ return obj
181
+ return jsonfile.edit_json(found.file, mutate, command="devcontainer", jsonc=True, confirm=confirm,
182
+ snippet=snippet(found.proposed))
183
+
184
+
185
+ # ---------------------------------------------------------------- status
186
+
187
+ def _pin_state(repo: Path) -> tuple:
188
+ """(pinned, pin file, "Name <email>" or None)."""
189
+ from . import pin # lazily: pin pulls in render, and building the parser must not
190
+ path = pin._pin_file(str(repo))
191
+ included = pin.FILE in pin.git(str(repo), "config", "--local", "--get-all", "include.path", ok=(0, 1)).split("\n")
192
+ if not (included and path.is_file()):
193
+ return False, path, None
194
+ vals = [pin.git(str(repo), "config", "-f", str(path), "--get", f"user.{k}", ok=(0, 1)) for k in ("name", "email")]
195
+ return True, path, f"{vals[0]} <{vals[1]}>".strip()
196
+
197
+
198
+ def status(repo: Path, cfg: Optional[config.Config], cfg_error: Optional[str] = None) -> dict:
199
+ """What `devcontainer status` reports: the file, the pin, the container. cfg None: no config here, or
200
+ (cfg_error) one that cannot be read. Each problem says what it is `about`: devcontainer.json, config, pin."""
201
+ file = locate(repo)
202
+ problems: List[dict] = []
203
+ dc_pin = False
204
+ if file is not None:
205
+ try:
206
+ dc_pin = has_pin(_read(file)[0].get(KEY))
207
+ except ConfigError as e:
208
+ problems.append({"level": "warning", "about": "devcontainer.json", "message": str(e), "fix": None})
209
+ if cfg_error:
210
+ problems.append({"level": "warning", "about": "config", "fix": None,
211
+ "message": f"the config cannot be parsed: {cfg_error}"})
212
+ pinned, pin_file, who = _pin_state(repo)
213
+ stale: Optional[bool] = None
214
+ if cfg is not None:
215
+ stale = pinned and any(p.code == "pin_stale" for p in resolve.compare(repo, cfg).problems)
216
+ inside = in_container()
217
+ fix = f"gitpro pin {_tilde(repo)}"
218
+ if inside and not pinned:
219
+ no_cfg = " and no host config" if cfg is None and not cfg_error else ""
220
+ problems.append({"level": "error", "about": "pin", "fix": fix,
221
+ "message": f"not pinned{no_cfg}: run on the host: {fix}"})
222
+ if stale:
223
+ problems.append({"level": "warning", "about": "pin", "fix": fix, "message": f"pin is stale → {fix}"})
224
+ return {"repo": str(repo), "file": str(file) if file else None, "has_pin": dc_pin, "pinned": pinned,
225
+ "pin_file": str(pin_file), "pin_identity": who, "pin_stale": bool(stale), "in_container": inside,
226
+ "container_reason": _container_reason() if inside else None, "problems": problems}
227
+
228
+
229
+ def _rel(path: Path, repo: Path) -> str:
230
+ try:
231
+ return str(Path(path).relative_to(repo))
232
+ except ValueError:
233
+ return _tilde(path)
234
+
235
+
236
+ def _status_lines(st: dict, repo: Path, has_cfg: bool, cfg_error: Optional[str] = None) -> List[str]:
237
+ if st["file"] is None:
238
+ dc_line = "none (gitpro devcontainer prints the line once the repo has one)"
239
+ else:
240
+ rel = _rel(Path(st["file"]), repo)
241
+ bad = [p for p in st["problems"] if p["about"] == "devcontainer.json"]
242
+ dc_line = rel + " " + ("cannot parse ⚠" if bad else "initializeCommand runs gitpro pin ✓" if st["has_pin"]
243
+ else f"initializeCommand does not run gitpro pin → gitpro devcontainer {_tilde(repo)}")
244
+ if st["pinned"]:
245
+ state = ("stale ⚠" if st["pin_stale"] else "up to date ✓" if has_cfg
246
+ else "(the config cannot be parsed: not compared)" if cfg_error else "(no config here to compare)")
247
+ pin_line = f"{_rel(Path(st['pin_file']), repo)} {st['pin_identity']} {state}"
248
+ else:
249
+ pin_line = "not pinned ✗" if st["in_container"] else f"not pinned → gitpro pin {_tilde(repo)}"
250
+ where = f"yes ({st['container_reason']})" if st["in_container"] else "no"
251
+ return [_tilde(repo), f" devcontainer.json {dc_line}", f" pin {pin_line}",
252
+ f" inside container {where}"]
253
+
254
+
255
+ def cmd_status(args, top: Path) -> int:
256
+ args.command = "devcontainer status"
257
+ cfg: Optional[config.Config] = None
258
+ cfg_error: Optional[str] = None
259
+ try:
260
+ path, how = config.find(args.config, Path(args.home).expanduser().resolve())
261
+ except ConfigError:
262
+ path = None # no config on this machine (normal inside a container)
263
+ if path is not None:
264
+ try:
265
+ cfg = config.load_all(path, how)
266
+ except (ConfigError, OSError, UnicodeError) as e:
267
+ cfg_error = str(e)
268
+ st = status(top, cfg, cfg_error)
269
+ rc = 1 if st["problems"] else 0
270
+ if jsonout.wants_json(args):
271
+ jsonout.emit("devcontainer status", st)
272
+ return rc
273
+ print("\n".join(_status_lines(st, top, cfg is not None, cfg_error)))
274
+ for p in st["problems"]:
275
+ print(("✗ " if p["level"] == "error" else "⚠ ") + p["message"], file=sys.stderr)
276
+ return rc
277
+
278
+
279
+ # ---------------------------------------------------------------- the snippet and --write
280
+
281
+ def _print_snippet(f: Found) -> None:
282
+ how = "tracked: committed to the repo" if f.tracked else "not committed: only on this machine"
283
+ print(f"{_tilde(f.file)} ({how})")
284
+ if f.existing is None:
285
+ print("add this key:")
286
+ else:
287
+ print(f"now:\n {snippet(f.existing)}\nchange it to (your command is kept as it is):")
288
+ print(f" {snippet(f.proposed)}")
289
+ if f.merged:
290
+ print(f"· {MERGED}")
291
+ if f.has_comments:
292
+ print("· the file has comments, which --write would lose: add the key by hand")
293
+ elif f.tracked:
294
+ print("→ this file is shared with your team; gitpro does not edit it by default. "
295
+ f"Own repo? gitpro devcontainer --write {_tilde(f.repo)}")
296
+ else:
297
+ print(f"→ gitpro does not edit it by default; to add it: gitpro devcontainer --write {_tilde(f.repo)}")
298
+ print("→ in the container, 'gitpro which' shows the pinned identity; signing and ssh keys are not pinned "
299
+ "(see 'gitpro pin')")
300
+ print(f"· {NOTE}")
301
+
302
+
303
+ def _ask(args, f: Found):
304
+ def confirm(diff: str) -> bool:
305
+ if not args.yes and not sys.stdin.isatty():
306
+ raise ConfigError("no terminal to confirm on; re-run with --yes")
307
+ if not args.json:
308
+ sys.stdout.write(diff if diff.endswith("\n") else diff + "\n")
309
+ sign = "~" if f.existing is not None else "+"
310
+ print(f" backup {_tilde(jsonfile.undo.state_dir())}/backup-<timestamp>/{f.file.name}")
311
+ print(f" update {_tilde(f.file)} ({sign} {KEY})")
312
+ if args.yes:
313
+ return True
314
+ from . import view
315
+ return view.confirm("write it?")
316
+ return confirm
317
+
318
+
319
+ def _refuse(args, f: Found, why: str) -> int:
320
+ """Exit 2 without writing: the snippet on stdout and `✗ <why>; add the key by hand` (JSON: ok false)."""
321
+ if args.json:
322
+ jsonout.emit("devcontainer", {**f.to_json(), "ok": False, "error": f"{why}; add the key by hand: "
323
+ f"{snippet(f.proposed)}"})
324
+ return 2
325
+ _print_snippet(f)
326
+ return fail(f"{why}; add the key by hand (printed above)", args)
327
+
328
+
329
+ def _outside(f: Found) -> Optional[Path]:
330
+ """The file's real path when a symlink takes it out of the repo's real top level, else None."""
331
+ real = Path(os.path.realpath(f.file))
332
+ top = Path(os.path.realpath(f.repo))
333
+ return None if top in real.parents else real
334
+
335
+
336
+ def cmd_write(args, f: Found) -> int:
337
+ if args.json and not args.yes:
338
+ return fail("--write --json needs -y: the question would mix with the JSON", args)
339
+ if not args.json:
340
+ if f.tracked:
341
+ print(f"⚠ {_tilde(f.file)} is tracked by git and shared with your team", file=sys.stderr)
342
+ print(f"⚠ {TEAM}", file=sys.stderr)
343
+ if f.has_comments:
344
+ return _refuse(args, f, f"{_tilde(f.file)} has comments")
345
+ real = _outside(f)
346
+ if real is not None:
347
+ return _refuse(args, f, f"{_tilde(f.file)} is a symlink to {_tilde(real)}, outside the repo; gitpro "
348
+ "edits only files inside the repo")
349
+ if not os.access(os.path.realpath(f.file), os.W_OK):
350
+ return _refuse(args, f, f"{_tilde(f.file)} is read-only (make it writable to use --write)")
351
+ res = write(f, confirm=_ask(args, f))
352
+ if not res.changed:
353
+ if args.json:
354
+ jsonout.emit("devcontainer", f.to_json())
355
+ else:
356
+ print("✗ aborted, nothing written")
357
+ return 1
358
+ if args.json:
359
+ jsonout.emit("devcontainer", {**f.to_json(), "written": True, "backup": str(res.backup),
360
+ "undo": str(res.undo)})
361
+ return 0
362
+ print("✓ written; the next container start runs gitpro pin on the host")
363
+ if f.merged:
364
+ print(f"· {MERGED}")
365
+ print(f" backup {_tilde(res.backup)}")
366
+ print(f"↩ undo: sh {_tilde(res.undo)}")
367
+ print(f"→ also pin now: gitpro pin {_tilde(f.repo)}")
368
+ return 0
369
+
370
+
371
+ def _no_file(args, top: Path) -> int:
372
+ msg = (f"no devcontainer.json in {_tilde(top)} (looked at {', '.join(CANDIDATES)}); create one with the "
373
+ "Dev Containers extension or the devcontainer CLI first")
374
+ if args.json:
375
+ jsonout.emit("devcontainer", {"ok": False, "error": msg, "file": None, "tracked": False, "has_pin": False,
376
+ "existing_command": None, "proposed": LINE, "has_comments": False,
377
+ "written": False})
378
+ return 2
379
+ return fail(msg, args)
380
+
381
+
382
+ def cmd_devcontainer(args) -> int:
383
+ words = list(args.words)
384
+ is_status = bool(words) and words[0] == "status"
385
+ if is_status:
386
+ words.pop(0)
387
+ if len(words) > 1:
388
+ return fail(f"one directory at most (got {len(words)}); usage: gitpro devcontainer [status] [DIR]", args)
389
+ if is_status and args.write:
390
+ return fail("devcontainer status does not take --write", args)
391
+ resolve.require_git()
392
+ where = words[0] if words else "."
393
+ top = resolve.is_repo(Path(where))
394
+ if top is None:
395
+ return fail(f"{where}: not a git repository", args)
396
+ if is_status:
397
+ return cmd_status(args, top)
398
+ path = locate(top)
399
+ if path is None:
400
+ return _no_file(args, top)
401
+ f = inspect(top, path)
402
+ if f.has_pin:
403
+ if args.json:
404
+ jsonout.emit("devcontainer", f.to_json())
405
+ else:
406
+ print(f"✓ already set up: {_tilde(path)} runs gitpro pin in {KEY}")
407
+ return 0
408
+ if args.write:
409
+ return cmd_write(args, f)
410
+ if args.json:
411
+ jsonout.emit("devcontainer", f.to_json())
412
+ else:
413
+ _print_snippet(f)
414
+ return 0
415
+
416
+
417
+ def _intermixed(sp: argparse.ArgumentParser) -> None:
418
+ """Parse options between the words too (`devcontainer status --json DIR`), as clone does."""
419
+ def parse_known_args(args=None, namespace=None):
420
+ # parse_known_intermixed_args calls parse_known_args itself: the class's, not this override.
421
+ del sp.parse_known_args
422
+ try:
423
+ return sp.parse_known_intermixed_args(args, namespace)
424
+ finally:
425
+ sp.parse_known_args = parse_known_args # type: ignore[method-assign]
426
+ sp.parse_known_args = parse_known_args # type: ignore[method-assign]
427
+
428
+
429
+ def register(sub, common, confirm) -> None:
430
+ sp = sub.add_parser(
431
+ "devcontainer", parents=[common, confirm], formatter_class=argparse.RawDescriptionHelpFormatter,
432
+ help="print (or --write) the devcontainer.json line that runs gitpro pin on the host",
433
+ description="Print the devcontainer.json initializeCommand that runs 'gitpro pin' on the host before "
434
+ "every container start.\ndevcontainer.json is shared with your team, so gitpro only prints "
435
+ "it; --write edits it with a backup and undo.\n'gitpro devcontainer status' shows the pin state.",
436
+ epilog=NOTE)
437
+ sp.add_argument("words", nargs="*", metavar="[status] [DIR]",
438
+ help="'status' to show the pin state; DIR is the repo (default: .)")
439
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
440
+ sp.set_defaults(func=lambda a: guarded(cmd_devcontainer, a))
441
+ _intermixed(sp)
442
+ capability("devcontainer", "devcontainer.write")