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/clone.py ADDED
@@ -0,0 +1,505 @@
1
+ """`gitpro clone <url>`: git clone with the identity, ssh alias and folder the URL calls for (spec 07).
2
+
3
+ plan() picks the identity exactly as git will afterwards (resolve.match_url on the URL), rewrites the
4
+ URL to the identity's ssh alias when the identity asks for it, and chooses the target directory from
5
+ clone_dir / [clone] and the layout. run() runs `git clone` with the caller's umask. cmd_clone() shows
6
+ the plan, asks, clones and checks the result with resolve.compare. No network beyond git's own.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import contextlib
12
+ import os
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import signal
17
+ import stat
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Iterator, List, Optional, Sequence, Tuple
24
+
25
+ from . import jsonout, resolve
26
+ from .args import capability
27
+ from .config import Config, ConfigError, Identity
28
+
29
+ # scheme -> what kind of access it is ("auth" in --json for a URL that is kept as given)
30
+ _SCHEMES = {"http": "https", "https": "https", "ssh": "ssh", "git+ssh": "ssh", "ssh+git": "ssh",
31
+ "git": "git", "file": "local"}
32
+ _SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*)://")
33
+ _SEG = r"[A-Za-z0-9._-]+"
34
+ _SHORT_RE = re.compile(rf"^{_SEG}(?:/{_SEG})+$") # owner/repo, group/sub/repo, host/owner/repo
35
+ _PLACEHOLDER_RE = re.compile(r"\{(host|owner|repo|id)\}")
36
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ClonePlan:
41
+ url: str # as given
42
+ clone_url: str # what git clone gets (and stores as origin)
43
+ dir: Path # absolute target
44
+ identity: Optional[Identity] # None -> [default]
45
+ auth: str # ssh-alias | ssh | https | git | local
46
+ host: Optional[str] # the real host (never the alias); None for a local source
47
+ owner: Optional[str]
48
+ repo: str
49
+ name: Optional[str] # who git will commit as (identity, else [default])
50
+ email: Optional[str]
51
+ key: Optional[str] # the ssh key git will use (from the identity matching clone_url)
52
+ forced: bool # identity came from --identity
53
+ warnings: Tuple[str, ...] = ()
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class _Source:
58
+ url: str # the URL to match and clone (shorthands expanded)
59
+ kind: str # https | ssh | git | local
60
+ host: Optional[str]
61
+ owner: Optional[str]
62
+ repo: str
63
+
64
+
65
+ # ---------------------------------------------------------------- plan
66
+
67
+ def _ssh_url(user: str, host: str, port: Optional[int], path: str) -> str:
68
+ return f"ssh://{user}@{host}:{port}/{path}.git" if port else f"{user}@{host}:{path}.git"
69
+
70
+
71
+ def _find(cfg: Config, want: str) -> Identity:
72
+ for i in cfg.identities:
73
+ if i.id == want:
74
+ return i
75
+ have = ", ".join(i.id for i in cfg.identities) or "none"
76
+ raise ConfigError(f"no identity {want!r} (have: {have})")
77
+
78
+
79
+ def _source(url: str, ident: Optional[Identity], cwd: Path) -> _Source:
80
+ """What the user typed, classified: a URL with a scheme, scp-like, a local path, or a shorthand."""
81
+ bad = ConfigError(f"cannot parse {url!r} as a git URL")
82
+ if not url.strip() or _CONTROL_RE.search(url) or url.startswith("-"):
83
+ raise bad
84
+ m = _SCHEME_RE.match(url)
85
+ if m:
86
+ kind = _SCHEMES.get(m[1].lower())
87
+ if kind == "https": # a browser URL: ?tab=readme, #readme are not part of the repo
88
+ url = url.split("#", 1)[0].split("?", 1)[0]
89
+ host, owner, repo = resolve.parse_url(url)
90
+ if kind is None or not repo or (kind != "local" and not host):
91
+ raise bad
92
+ return _Source(url, kind, None if kind == "local" else host, None if kind == "local" else owner, repo)
93
+ if "::" in url: # transport helpers (ext::, fd::) are not clone sources gitpro plans
94
+ raise bad
95
+ local = url.startswith(("/", "./", "../")) or os.path.exists(cwd / url)
96
+ if not local and ":" in url and "/" not in url.split(":", 1)[0]: # git's scp-like rule
97
+ host, owner, repo = resolve.parse_url(url)
98
+ if not host or not repo:
99
+ raise bad
100
+ return _Source(url, "ssh", host, owner, repo)
101
+ if not local and _SHORT_RE.match(url):
102
+ parts = url.split("/")
103
+ if len(parts) >= 3 and "." in parts[0]: # gh-style host/owner/repo
104
+ full = "https://" + url
105
+ host, owner, repo = resolve.parse_url(full)
106
+ return _Source(full, "https", host, owner, repo or parts[-1])
107
+ if ident is None:
108
+ raise ConfigError(f"'{url}' needs --identity ID (which host?)")
109
+ owner, repo = "/".join(parts[:-1]), resolve.parse_url(url)[2] or parts[-1]
110
+ if ident.ssh_key or ident.ssh_alias:
111
+ full = _ssh_url(ident.ssh_user, ident.host, ident.ssh_port, f"{owner}/{repo}")
112
+ return _Source(full, "ssh", ident.host, owner, repo)
113
+ return _Source(f"https://{ident.host}/{owner}/{repo}", "https", ident.host, owner, repo)
114
+ repo = resolve.parse_url(url)[2]
115
+ if not local or not repo:
116
+ raise bad
117
+ return _Source(url, "local", None, None, repo)
118
+
119
+
120
+ def _expand(p: str, home: Path) -> Path:
121
+ return home / p[2:] if p.startswith("~/") else Path(p)
122
+
123
+
124
+ def _tilde(path: Path, home: Path) -> str:
125
+ from .plan import Env
126
+ return Env(home).tilde(Path(path))
127
+
128
+
129
+ def _target(src: _Source, cfg: Config, ident: Optional[Identity], host: Optional[str], dir: Optional[str],
130
+ cwd: Path, home: Path) -> Path:
131
+ if dir:
132
+ return Path(os.path.abspath(cwd / os.path.expanduser(str(dir))))
133
+ raw = (ident.clone_dir if ident else cfg.default.clone_dir) or cfg.clone.dir
134
+ base = Path(os.path.abspath(_expand(raw, home) if raw else cwd))
135
+ if src.kind == "local":
136
+ rel = src.repo
137
+ else:
138
+ vals = {"host": host or "", "owner": src.owner or "", "repo": src.repo, "id": ident.id if ident else "default"}
139
+ rel = _PLACEHOLDER_RE.sub(lambda m: vals[m[1]], cfg.clone.layout)
140
+ parts = [s for s in rel.split("/") if s]
141
+ target = base.joinpath(*parts) if parts else base
142
+ if not parts or any(s in (".", "..") for s in parts) or os.path.commonpath([base, target]) != str(base):
143
+ raise ConfigError(f"target leaves {_tilde(base, home)}")
144
+ return target
145
+
146
+
147
+ def plan(url: str, cfg: Config, *, dir: Optional[str] = None, identity: Optional[str] = None, ssh: bool = False,
148
+ no_rewrite: bool = False, cwd: Optional[Path] = None, home: Optional[Path] = None) -> ClonePlan:
149
+ """Everything `gitpro clone` will do, without doing it. Raises ConfigError for an unparsable URL, an
150
+ unknown --identity, a target outside its base, or a target that exists."""
151
+ home = Path.home() if home is None else Path(home)
152
+ cwd = Path(os.getcwd() if cwd is None else cwd)
153
+ forced = identity is not None
154
+ ident = _find(cfg, identity) if forced else None
155
+ src = _source(url, ident, cwd)
156
+ if not forced:
157
+ ident = resolve.match_url(src.url, cfg)[0]
158
+ # An alias URL names the alias' host, also when its owner matches no identity (spec §2.4: {host} is real).
159
+ real = next((i.host for i in cfg.identities if src.host and i.ssh_alias == src.host), src.host)
160
+ on_host = ident is not None and src.host is not None and (real == ident.host or src.host == ident.ssh_alias)
161
+ host = ident.host if on_host else real
162
+ path = "/".join(x for x in (src.owner, src.repo) if x)
163
+ if on_host and ident.ssh_alias and ident.rewrite and not no_rewrite:
164
+ # What the generated url.insteadOf gives: user@alias:owner/... (the alias' ssh Host carries the port).
165
+ clone_url, auth = f"{ident.ssh_user}@{ident.ssh_alias}:{path}.git", "ssh-alias"
166
+ elif ssh and src.kind in ("https", "git"):
167
+ clone_url, auth = _ssh_url(ident.ssh_user if on_host else "git", host, ident.ssh_port if on_host else None,
168
+ path), "ssh"
169
+ else:
170
+ clone_url, auth = src.url, src.kind
171
+ target = _target(src, cfg, ident, host, dir, cwd, home)
172
+ if os.path.lexists(target):
173
+ raise ConfigError(f"{_tilde(target, home)} already exists")
174
+ warnings = []
175
+ after = resolve.match_url(clone_url, cfg)[0] # what git will include for the stored remote url
176
+ if after != ident:
177
+ warnings.append(f"git will use {after.id if after else '[default]'} for {resolve.redact_url(clone_url)}, not "
178
+ f"{ident.id if ident else '[default]'}")
179
+ d = cfg.default
180
+ # The key git will use: the identity whose includeIf matches the stored URL (warned above when it differs).
181
+ key = after.ssh_key if after else (d.ssh_key if host in d.ssh_hosts else None)
182
+ return ClonePlan(url, clone_url, target, ident, auth, host, src.owner, src.repo,
183
+ ident.name if ident else d.name, ident.email if ident else d.email, key, forced,
184
+ tuple(warnings))
185
+
186
+
187
+ def preview(p: ClonePlan, home: Path) -> List[str]:
188
+ """The lines shown before asking (and by --dry-run)."""
189
+ key = p.key if not p.key or p.key.startswith("~/") else _tilde(Path(p.key), home)
190
+ lines = [f"clone {resolve.redact_url(p.clone_url)}", f" into {_tilde(p.dir, home)}"]
191
+ i = p.identity
192
+ if i is not None:
193
+ why = "--identity" if p.forced else (f"owner {p.owner} on {p.host}" if i.owners else f"any owner on {p.host}")
194
+ lines.append(f" identity {i.id} {i.name} <{i.email}> ({why})")
195
+ else:
196
+ who = f"{p.name or '(no name)'} <{p.email}>" if p.email else "no name/email in [default]; git will refuse to commit"
197
+ where = f"{p.host}/{p.owner}" if p.host and p.owner else p.host or "a local path"
198
+ lines.append(f" identity (default) {who} (no identity matches {where})")
199
+ if p.auth == "ssh-alias":
200
+ lines.append(f" ssh alias {i.ssh_alias} → {i.host}" + (f", key {key}" if key else ""))
201
+ elif p.auth == "ssh":
202
+ lines.append(f" ssh key {key}" if key else " ssh your ssh config picks the key")
203
+ elif p.auth == "https":
204
+ lines.append(" auth https (credential helper)" + (f"; add --ssh to use key {key}" if key else ""))
205
+ return lines
206
+
207
+
208
+ # ---------------------------------------------------------------- run
209
+
210
+ class _Terminated(Exception):
211
+ pass
212
+
213
+
214
+ @contextlib.contextmanager
215
+ def _sigterm_raises() -> Iterator[None]:
216
+ """The first SIGTERM (VS Code's cancel) raises _Terminated; later ones are ignored so cleanup finishes."""
217
+ fired = []
218
+
219
+ def handler(signum, frame):
220
+ if not fired:
221
+ fired.append(signum)
222
+ raise _Terminated()
223
+ try:
224
+ old = signal.signal(signal.SIGTERM, handler)
225
+ except ValueError: # not the main thread: leave signals alone
226
+ old = None
227
+ try:
228
+ yield
229
+ finally:
230
+ if old is not None:
231
+ signal.signal(signal.SIGTERM, old)
232
+
233
+
234
+ @contextlib.contextmanager
235
+ def _umask(mask: Optional[int]) -> Iterator[None]:
236
+ old = os.umask(mask) if mask is not None else None
237
+ try:
238
+ yield
239
+ finally:
240
+ if old is not None:
241
+ os.umask(old)
242
+
243
+
244
+ def _make_parents(path: Path, umask: Optional[int]) -> List[Path]:
245
+ """mkdir -p path (0755 under the caller's umask); returns the directories this created, outermost first."""
246
+ missing = []
247
+ while not os.path.lexists(path) and path != path.parent:
248
+ missing.append(path)
249
+ path = path.parent
250
+ created = []
251
+ with _umask(umask):
252
+ for d in reversed(missing):
253
+ try:
254
+ os.mkdir(d, 0o755)
255
+ except FileExistsError:
256
+ if not d.is_dir():
257
+ raise
258
+ continue # someone else made it meanwhile: not ours to remove
259
+ created.append(d)
260
+ return created
261
+
262
+
263
+ def _make_target(path: Path, umask: Optional[int]) -> Tuple[int, int]:
264
+ """Create the empty target (atomic: fails when it exists) and return its (st_dev, st_ino)."""
265
+ with _umask(umask):
266
+ try:
267
+ os.mkdir(path, 0o777) # what git itself would use: the caller's umask decides
268
+ except FileExistsError:
269
+ raise ConfigError(f"{path} already exists") from None
270
+ st = os.lstat(path)
271
+ return st.st_dev, st.st_ino
272
+
273
+
274
+ def _cleanup(path: Path, made: Optional[Tuple[int, int]], parents: List[Path]) -> bool:
275
+ """Remove the target if it is still the directory gitpro made, then the parents gitpro made that are empty."""
276
+ removed = False
277
+ if made is not None:
278
+ try:
279
+ st = os.lstat(path)
280
+ except OSError:
281
+ st = None
282
+ if st is not None and stat.S_ISDIR(st.st_mode) and (st.st_dev, st.st_ino) == made:
283
+ shutil.rmtree(path, ignore_errors=True)
284
+ removed = not os.path.lexists(path)
285
+ for d in reversed(parents):
286
+ try:
287
+ os.rmdir(d)
288
+ except OSError:
289
+ break
290
+ return removed
291
+
292
+
293
+ def _detach() -> bool:
294
+ """Start git in its own session (so a cancel can stop git *and* its ssh) unless a person may have to answer
295
+ ssh on the terminal: ssh prompts on /dev/tty, which a new session no longer has."""
296
+ if os.name == "nt":
297
+ return False
298
+ if not (os.isatty(0) or os.isatty(2)):
299
+ return True
300
+ try:
301
+ os.close(os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY))
302
+ except OSError:
303
+ return True
304
+ return False
305
+
306
+
307
+ def _stop(proc: subprocess.Popen, group: bool) -> None:
308
+ """SIGTERM git (and, detached, everything it started, ssh included); SIGKILL after 2 s."""
309
+ if not group:
310
+ if proc.poll() is None:
311
+ proc.terminate()
312
+ try:
313
+ proc.wait(timeout=2)
314
+ except subprocess.TimeoutExpired:
315
+ proc.kill()
316
+ proc.wait()
317
+ return
318
+
319
+ def send(sig: int) -> bool:
320
+ try:
321
+ os.killpg(proc.pid, sig)
322
+ return True
323
+ except (ProcessLookupError, PermissionError):
324
+ return False
325
+
326
+ deadline = time.monotonic() + 2
327
+ send(signal.SIGTERM)
328
+ while True:
329
+ proc.poll() # reap the leader, but its exit does not mean ssh has stopped
330
+ if not send(0):
331
+ break
332
+ remaining = deadline - time.monotonic()
333
+ if remaining <= 0:
334
+ send(signal.SIGKILL)
335
+ break
336
+ time.sleep(min(0.05, remaining))
337
+ proc.wait()
338
+
339
+
340
+ def run(p: ClonePlan, git_args: Sequence[str] = (), *, umask: Optional[int] = None, stdout=None) -> int:
341
+ """git clone [git_args] -- <clone_url> <dir>; returns git's exit code. stdin and stderr are inherited
342
+ (progress, passphrases); umask is the caller's, so the clone is not private to gitpro's 077.
343
+ The target is created here (atomically, empty) and git clones into it. When git fails or is interrupted
344
+ (Ctrl-C, SIGTERM) git is stopped, and the target and any parents this made are removed, the target only
345
+ while it is still the directory made here."""
346
+ argv = ["git", "clone", *git_args, "--", p.clone_url, str(p.dir)]
347
+ env = {k: v for k, v in os.environ.items() if k not in resolve._REDIRECT_VARS}
348
+ kw = {} if umask is None else {"umask": umask}
349
+ group = _detach()
350
+ parents: List[Path] = []
351
+ made: Optional[Tuple[int, int]] = None
352
+ proc: Optional[subprocess.Popen] = None
353
+ with _sigterm_raises():
354
+ try:
355
+ parents = _make_parents(p.dir.parent, umask)
356
+ made = _make_target(p.dir, umask)
357
+ try:
358
+ proc = subprocess.Popen(argv, env=env, stdout=stdout, start_new_session=group, **kw)
359
+ except FileNotFoundError:
360
+ raise ConfigError("git not found") from None
361
+ rc = proc.wait()
362
+ except (KeyboardInterrupt, _Terminated):
363
+ if proc is not None:
364
+ _stop(proc, group)
365
+ what = f"; removed the partial clone {p.dir}" if _cleanup(p.dir, made, parents) else ""
366
+ print(f"✗ interrupted{what}", file=sys.stderr)
367
+ raise KeyboardInterrupt from None
368
+ except BaseException:
369
+ _cleanup(p.dir, made, parents)
370
+ raise
371
+ if rc != 0:
372
+ _cleanup(p.dir, made, parents)
373
+ return rc
374
+
375
+
376
+ # ---------------------------------------------------------------- command
377
+
378
+ def _again(args) -> str:
379
+ """The command that performs what --dry-run showed."""
380
+ from .cli import _sh
381
+ words = [shlex.quote(w) for w in ("gitpro", "clone", args.url)]
382
+ if args.dir:
383
+ words.append(_sh(str(args.dir)))
384
+ if args.config:
385
+ words += ["-c", _sh(str(args.config))]
386
+ if args.identity:
387
+ words += ["--identity", shlex.quote(args.identity)]
388
+ words += [f for f, on in (("--ssh", args.ssh), ("--no-rewrite", args.no_rewrite)) if on]
389
+ words.append("-y")
390
+ if args.git_args:
391
+ words += ["--", *map(shlex.quote, args.git_args)]
392
+ return " ".join(words)
393
+
394
+
395
+ def _json(p: ClonePlan, **extra) -> dict:
396
+ out = {"url": resolve.redact_url(p.url), "clone_url": resolve.redact_url(p.clone_url), "dir": str(p.dir),
397
+ "identity": p.identity.id if p.identity else None, "auth": p.auth} # url, clone_url: for display
398
+ out.update(extra)
399
+ if p.warnings:
400
+ out["warnings"] = [f"⚠ {w}" for w in p.warnings]
401
+ return out
402
+
403
+
404
+ def _problem_line(prob: resolve.Problem, p: ClonePlan, top: Path, home: Path) -> str:
405
+ from .cli import _sh
406
+ if prob.code == "no_identity":
407
+ where = f"{p.host}/{p.owner}" if p.host and p.owner else resolve.redact_url(p.clone_url)
408
+ return (f"no identity for {where}: git will refuse to commit here → "
409
+ f"gitpro scan --suggest --repo {_sh(_tilde(top, home))}")
410
+ fix = resolve._shown_fix(prob)
411
+ return prob.message + (f" → {fix}" if fix else "")
412
+
413
+
414
+ def cmd_clone(args) -> int:
415
+ from . import cli, view
416
+ cfg, env = cli._load_all(args)
417
+ as_json = bool(getattr(args, "json", False))
418
+ p = plan(args.url, cfg, dir=args.dir, identity=args.identity, ssh=args.ssh, no_rewrite=args.no_rewrite,
419
+ home=env.home)
420
+ if not as_json:
421
+ print("\n".join(preview(p, env.home)), flush=True)
422
+ for w in p.warnings:
423
+ view.warn(w)
424
+ if args.dry_run or not (args.yes or args.write):
425
+ if as_json:
426
+ jsonout.emit("clone", _json(p, dry_run=True, problems=[], git_exit=None))
427
+ else:
428
+ view.step(_again(args))
429
+ return 0
430
+ if as_json and not args.yes:
431
+ raise ConfigError("no terminal to confirm on with --json; re-run with --yes (or --dry-run)")
432
+ if not cli._confirm("clone?", args.yes):
433
+ view.aborted()
434
+ return 1
435
+ sys.stdout.flush()
436
+ rc = run(p, args.git_args, umask=cli.caller_umask(), stdout=2 if as_json else None)
437
+ if rc != 0:
438
+ msg = f"git clone failed ({rc})"
439
+ if as_json:
440
+ jsonout.emit("clone", _json(p, dry_run=False, problems=[], git_exit=rc, error=msg), ok=False)
441
+ else:
442
+ view.error(msg)
443
+ return 2
444
+ try:
445
+ rep: Optional[resolve.Report] = resolve.compare(p.dir, cfg, home=env.home)
446
+ except ConfigError as e:
447
+ rep = None
448
+ view.warn(f"cloned, but could not check the identity: {e}")
449
+ if as_json:
450
+ probs = rep.to_json()["problems"] if rep else []
451
+ jsonout.emit("clone", _json(p, dry_run=False, problems=probs, git_exit=0))
452
+ return 0
453
+ shown = _tilde(p.dir, env.home)
454
+ if rep is not None and rep.ok():
455
+ e = rep.expected
456
+ view.ok(f"cloned into {shown}; identity {e.id or '(default)'} <{e.email}>")
457
+ else:
458
+ view.ok(f"cloned into {shown}")
459
+ if rep is not None:
460
+ view.warn(_problem_line(rep.problems[0], p, rep.repo, env.home))
461
+ print(f"↩ undo: rm -r {cli._sh(shown)}")
462
+ return 0
463
+
464
+
465
+ def _split_git_args(sp: argparse.ArgumentParser) -> None:
466
+ """Everything after the first `--` goes verbatim to git clone (args.git_args), never to argparse; the rest
467
+ is parsed intermixed, so options may sit between URL and DIR (`clone URL --ssh DIR`)."""
468
+ def parse_known_args(args=None, namespace=None):
469
+ args = list(sys.argv[1:] if args is None else args)
470
+ tail: List[str] = []
471
+ if "--" in args:
472
+ cut = args.index("--")
473
+ args, tail = args[:cut], args[cut + 1:]
474
+ # parse_known_intermixed_args calls parse_known_args itself: the class's, not this override.
475
+ del sp.parse_known_args
476
+ try:
477
+ ns, extras = sp.parse_known_intermixed_args(args, namespace)
478
+ finally:
479
+ sp.parse_known_args = parse_known_args # type: ignore[method-assign]
480
+ ns.git_args = tail
481
+ return ns, extras
482
+ sp.parse_known_args = parse_known_args # type: ignore[method-assign]
483
+
484
+
485
+ def register(sub, common, confirm) -> None:
486
+ sp = sub.add_parser(
487
+ "clone", parents=[common, confirm],
488
+ help="git clone with the identity, ssh alias and folder the URL calls for",
489
+ usage="gitpro clone [-h] URL [DIR] [--identity ID] [--ssh] [--no-rewrite] [--dry-run] [-y] [--json] "
490
+ "[-- GIT_CLONE_ARGS...]",
491
+ description="Picks the identity from the URL, rewrites it to the identity's ssh alias when the identity "
492
+ "has one, clones into clone_dir/<host>/<owner>/<repo> and checks the result. An https URL "
493
+ "stays https unless you pass --ssh. Set clone_dir per identity or [clone] dir; change the "
494
+ "folder shape with [clone] layout. Arguments after -- go to git clone.")
495
+ sp.add_argument("url", metavar="URL", help="git URL, host/owner/repo, or owner/repo with --identity")
496
+ sp.add_argument("dir", nargs="?", metavar="DIR", help="target directory (default: from clone_dir and layout)")
497
+ sp.add_argument("--identity", metavar="ID", help="use this identity (and its clone_dir), not the URL's") \
498
+ .gitpro_values = "identities"
499
+ sp.add_argument("--ssh", action="store_true", help="clone an https URL over ssh with the identity's key")
500
+ sp.add_argument("--no-rewrite", action="store_true", help="keep the URL; don't switch to the identity's ssh alias")
501
+ sp.add_argument("--dry-run", action="store_true", help="show the plan and exit")
502
+ sp.add_argument("--json", action="store_true", help="machine-readable output (needs -y or --dry-run)")
503
+ sp.set_defaults(func=cmd_clone, git_args=[])
504
+ _split_git_args(sp)
505
+ capability("clone")