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/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
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")
|