dktcli 1.0.0__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.
- devkittools/__init__.py +3 -0
- devkittools/__main__.py +6 -0
- devkittools/cli.py +179 -0
- devkittools/tools/__init__.py +1 -0
- devkittools/tools/bench.py +73 -0
- devkittools/tools/changeloggen.py +181 -0
- devkittools/tools/clean.py +110 -0
- devkittools/tools/colortool.py +126 -0
- devkittools/tools/commitwriter.py +199 -0
- devkittools/tools/contextpacker.py +226 -0
- devkittools/tools/cron.py +152 -0
- devkittools/tools/doctor.py +74 -0
- devkittools/tools/du.py +72 -0
- devkittools/tools/enc.py +131 -0
- devkittools/tools/envtool.py +141 -0
- devkittools/tools/findtool.py +119 -0
- devkittools/tools/gen.py +182 -0
- devkittools/tools/gx.py +184 -0
- devkittools/tools/hashtool.py +93 -0
- devkittools/tools/httptool.py +131 -0
- devkittools/tools/ignore.py +213 -0
- devkittools/tools/initproj.py +166 -0
- devkittools/tools/jsontool.py +200 -0
- devkittools/tools/net.py +114 -0
- devkittools/tools/portkiller.py +246 -0
- devkittools/tools/regextool.py +84 -0
- devkittools/tools/repostats.py +197 -0
- devkittools/tools/secrets_scan.py +108 -0
- devkittools/tools/serve.py +118 -0
- devkittools/tools/snip.py +94 -0
- devkittools/tools/system.py +157 -0
- devkittools/tools/timetool.py +130 -0
- devkittools/tools/todo.py +69 -0
- devkittools/tools/tree.py +86 -0
- devkittools/tools/watch.py +82 -0
- devkittools/utils.py +561 -0
- dktcli-1.0.0.dist-info/METADATA +148 -0
- dktcli-1.0.0.dist-info/RECORD +42 -0
- dktcli-1.0.0.dist-info/WHEEL +5 -0
- dktcli-1.0.0.dist-info/entry_points.txt +3 -0
- dktcli-1.0.0.dist-info/licenses/LICENSE +21 -0
- dktcli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""dkt find (fd) - fast project-wide text search (like grep/ripgrep) and file finder."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import fnmatch
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from ..utils import DktError, c, color_enabled, is_binary_chunk, list_project_files, resolve_folder
|
|
9
|
+
|
|
10
|
+
META = ("Project & Code", "find", "fd", "search text in a project (respects .gitignore) or find files")
|
|
11
|
+
|
|
12
|
+
EPILOG = """\
|
|
13
|
+
examples:
|
|
14
|
+
dkt fd "def main" search for text/regex in every file
|
|
15
|
+
dkt fd TODO -i --ext .py .js case-insensitive, only some extensions
|
|
16
|
+
dkt fd "class \\w+Error" -C 2 2 lines of context
|
|
17
|
+
dkt fd -n "*.test.js" find files by name (glob)
|
|
18
|
+
dkt fd foo -l only list matching file names
|
|
19
|
+
dkt fd "old_name" --replace new_name --yes project-wide find & replace
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(args) -> int:
|
|
24
|
+
folder = resolve_folder(args.folder)
|
|
25
|
+
files = list_project_files(folder)
|
|
26
|
+
if args.ext:
|
|
27
|
+
exts = tuple(e if e.startswith(".") else "." + e for e in args.ext)
|
|
28
|
+
files = [f for f in files if str(f).lower().endswith(exts)]
|
|
29
|
+
if args.name:
|
|
30
|
+
pat = args.name.lower()
|
|
31
|
+
hits = [f for f in files if fnmatch.fnmatch(f.name.lower(), pat) or fnmatch.fnmatch(str(f).replace("\\", "/").lower(), pat)]
|
|
32
|
+
for f in hits:
|
|
33
|
+
print(str(f).replace("\\", "/"))
|
|
34
|
+
print(c("\n %d file%s" % (len(hits), "" if len(hits) == 1 else "s"), "gray"))
|
|
35
|
+
return 0 if hits else 1
|
|
36
|
+
if not args.pattern:
|
|
37
|
+
raise DktError("Give something to search for, or use -n \"*.py\" to find files by name.")
|
|
38
|
+
flags = re.I if args.i else 0
|
|
39
|
+
src = args.pattern
|
|
40
|
+
if args.fixed:
|
|
41
|
+
src = re.escape(args.pattern)
|
|
42
|
+
try:
|
|
43
|
+
rx = re.compile(src, flags)
|
|
44
|
+
except re.error as e:
|
|
45
|
+
raise DktError("Invalid pattern: %s (use -F for plain text)" % e)
|
|
46
|
+
|
|
47
|
+
total = 0
|
|
48
|
+
nfiles = 0
|
|
49
|
+
changed = 0
|
|
50
|
+
for rel in files:
|
|
51
|
+
p = folder / rel
|
|
52
|
+
try:
|
|
53
|
+
if p.stat().st_size > 2_000_000:
|
|
54
|
+
continue
|
|
55
|
+
raw = p.read_bytes()
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
if is_binary_chunk(raw[:4096]):
|
|
59
|
+
continue
|
|
60
|
+
text = raw.decode("utf-8", errors="replace")
|
|
61
|
+
lines = text.splitlines()
|
|
62
|
+
hit_idx = [i for i, ln in enumerate(lines) if rx.search(ln)]
|
|
63
|
+
if not hit_idx:
|
|
64
|
+
continue
|
|
65
|
+
nfiles += 1
|
|
66
|
+
total += len(hit_idx)
|
|
67
|
+
name = str(rel).replace("\\", "/")
|
|
68
|
+
if args.replace is not None:
|
|
69
|
+
if args.yes:
|
|
70
|
+
new = rx.sub(args.replace, text)
|
|
71
|
+
if new != text:
|
|
72
|
+
p.write_bytes(new.encode("utf-8"))
|
|
73
|
+
changed += 1
|
|
74
|
+
else:
|
|
75
|
+
print(" %s %d match%s" % (c(name, "bcyan"), len(hit_idx), "" if len(hit_idx) == 1 else "es"))
|
|
76
|
+
continue
|
|
77
|
+
if args.l:
|
|
78
|
+
print(name)
|
|
79
|
+
continue
|
|
80
|
+
print("\n" + c(name, "bold bmagenta"))
|
|
81
|
+
shown = set()
|
|
82
|
+
for i in hit_idx:
|
|
83
|
+
for j in range(max(0, i - args.C), min(len(lines), i + args.C + 1)):
|
|
84
|
+
if j in shown:
|
|
85
|
+
continue
|
|
86
|
+
shown.add(j)
|
|
87
|
+
ln = lines[j]
|
|
88
|
+
if len(ln) > 220:
|
|
89
|
+
ln = ln[:220] + "..."
|
|
90
|
+
if j == i or rx.search(lines[j]):
|
|
91
|
+
if color_enabled():
|
|
92
|
+
ln = rx.sub(lambda m: c(m.group(0), "reverse byellow") if m.group(0) else "", ln)
|
|
93
|
+
print(" %s:%s" % (c(str(j + 1).rjust(5), "bgreen"), ln))
|
|
94
|
+
else:
|
|
95
|
+
print(" %s %s" % (c(str(j + 1).rjust(5), "gray"), c(ln, "gray")))
|
|
96
|
+
if args.replace is not None and not args.yes:
|
|
97
|
+
print(c("\n dry run - %d match%s in %d files. Add --yes to apply." % (total, "" if total == 1 else "es", nfiles), "yellow"))
|
|
98
|
+
elif args.replace is not None:
|
|
99
|
+
print(c("\n replaced in %d file%s" % (changed, "" if changed == 1 else "s"), "green"))
|
|
100
|
+
else:
|
|
101
|
+
print(c("\n %d match%s in %d file%s" % (total, "" if total == 1 else "es", nfiles, "" if nfiles == 1 else "s"), "gray"))
|
|
102
|
+
return 0 if total else 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register(sub, common) -> None:
|
|
106
|
+
p = sub.add_parser("find", aliases=["fd"], parents=[common], help=META[3],
|
|
107
|
+
description="Search file contents or names inside a project.",
|
|
108
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
109
|
+
p.add_argument("pattern", nargs="?", help="regex (or plain text with -F)")
|
|
110
|
+
p.add_argument("-f", "--folder", metavar="PATH")
|
|
111
|
+
p.add_argument("-i", action="store_true", help="ignore case")
|
|
112
|
+
p.add_argument("-F", "--fixed", action="store_true", help="treat pattern as plain text")
|
|
113
|
+
p.add_argument("-C", type=int, default=0, metavar="N", help="lines of context")
|
|
114
|
+
p.add_argument("-l", action="store_true", help="only print file names")
|
|
115
|
+
p.add_argument("-n", "--name", metavar="GLOB", help="find files by name instead")
|
|
116
|
+
p.add_argument("--ext", nargs="+", metavar="EXT", help="only these extensions")
|
|
117
|
+
p.add_argument("--replace", metavar="TEXT", help="replace matches (dry run unless --yes)")
|
|
118
|
+
p.add_argument("--yes", action="store_true", help="apply --replace")
|
|
119
|
+
p.set_defaults(func=run)
|
devkittools/tools/gen.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""dkt gen (g) - generate UUIDs, ULIDs, passwords, tokens, short ids, lorem ipsum, slugs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
import time
|
|
11
|
+
import unicodedata
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from ..utils import DktError, c, info, output_result
|
|
15
|
+
|
|
16
|
+
META = ("Generate & Convert", "gen", "g", "UUIDs, ULIDs, passwords, tokens, lorem ipsum, slugs")
|
|
17
|
+
|
|
18
|
+
EPILOG = """\
|
|
19
|
+
examples:
|
|
20
|
+
dkt gen uuid -n 5 five UUIDv4
|
|
21
|
+
dkt gen uuid --v7 time-sortable UUIDv7
|
|
22
|
+
dkt gen ulid
|
|
23
|
+
dkt gen password -l 24 --copy
|
|
24
|
+
dkt gen token --bytes 32 64 hex chars, good for secrets / API keys
|
|
25
|
+
dkt gen id -l 10 short URL-safe id
|
|
26
|
+
dkt gen lorem -p 2 two paragraphs
|
|
27
|
+
dkt gen slug "Hello, World! Ünïcode"
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
31
|
+
_LOREM = ("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut "
|
|
32
|
+
"labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris "
|
|
33
|
+
"nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse "
|
|
34
|
+
"cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui "
|
|
35
|
+
"officia deserunt mollit anim id est laborum").split()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def uuid7() -> str:
|
|
39
|
+
ms = int(time.time() * 1000) & ((1 << 48) - 1)
|
|
40
|
+
rand_a = secrets.randbits(12)
|
|
41
|
+
rand_b = secrets.randbits(62)
|
|
42
|
+
value = (ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
|
|
43
|
+
return str(uuid.UUID(int=value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ulid() -> str:
|
|
47
|
+
ms = int(time.time() * 1000)
|
|
48
|
+
out = ""
|
|
49
|
+
for _ in range(10):
|
|
50
|
+
out = _CROCKFORD[ms & 31] + out
|
|
51
|
+
ms >>= 5
|
|
52
|
+
for _ in range(16):
|
|
53
|
+
out += _CROCKFORD[secrets.randbelow(32)]
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_password(length: int, symbols: bool, digits: bool, upper: bool, ambiguous: bool) -> str:
|
|
58
|
+
pools = [string.ascii_lowercase]
|
|
59
|
+
if upper:
|
|
60
|
+
pools.append(string.ascii_uppercase)
|
|
61
|
+
if digits:
|
|
62
|
+
pools.append(string.digits)
|
|
63
|
+
if symbols:
|
|
64
|
+
pools.append("!@#$%^&*()-_=+[]{};:,.?")
|
|
65
|
+
if not ambiguous:
|
|
66
|
+
pools = ["".join(ch for ch in p if ch not in "0O1lI|") for p in pools]
|
|
67
|
+
if length < len(pools):
|
|
68
|
+
raise DktError("Password length must be at least %d for these options." % len(pools))
|
|
69
|
+
chars = [secrets.choice(p) for p in pools] # guarantee one of each class
|
|
70
|
+
allchars = "".join(pools)
|
|
71
|
+
chars += [secrets.choice(allchars) for _ in range(length - len(chars))]
|
|
72
|
+
secrets.SystemRandom().shuffle(chars)
|
|
73
|
+
return "".join(chars)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def slugify(text: str, sep: str = "-") -> str:
|
|
77
|
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
|
78
|
+
text = re.sub(r"[^a-zA-Z0-9]+", sep, text.lower()).strip(sep)
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lorem(paragraphs: int, words: int) -> str:
|
|
83
|
+
def sentence() -> str:
|
|
84
|
+
n = 6 + secrets.randbelow(10)
|
|
85
|
+
s = " ".join(secrets.choice(_LOREM) for _ in range(n))
|
|
86
|
+
return s.capitalize() + "."
|
|
87
|
+
if words:
|
|
88
|
+
out = [secrets.choice(_LOREM) for _ in range(words)]
|
|
89
|
+
return (" ".join(out)).capitalize() + "."
|
|
90
|
+
paras = []
|
|
91
|
+
for i in range(paragraphs):
|
|
92
|
+
sents = [sentence() for _ in range(3 + secrets.randbelow(4))]
|
|
93
|
+
if i == 0:
|
|
94
|
+
sents[0] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
|
|
95
|
+
paras.append(" ".join(sents))
|
|
96
|
+
return "\n\n".join(paras)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run(args) -> int:
|
|
100
|
+
kind = args.kind
|
|
101
|
+
if not kind:
|
|
102
|
+
raise DktError("Pick what to generate: uuid, ulid, password, token, id, lorem, slug. See 'dkt gen --help'.")
|
|
103
|
+
lines = []
|
|
104
|
+
if kind == "uuid":
|
|
105
|
+
for _ in range(args.n):
|
|
106
|
+
lines.append(uuid7() if args.v7 else str(uuid.uuid4()))
|
|
107
|
+
if args.upper:
|
|
108
|
+
lines = [x.upper() for x in lines]
|
|
109
|
+
if args.no_dashes:
|
|
110
|
+
lines = [x.replace("-", "") for x in lines]
|
|
111
|
+
elif kind == "ulid":
|
|
112
|
+
lines = [ulid() for _ in range(args.n)]
|
|
113
|
+
elif kind == "password":
|
|
114
|
+
for _ in range(args.n):
|
|
115
|
+
lines.append(make_password(args.length, not args.no_symbols, not args.no_digits,
|
|
116
|
+
not args.no_upper, args.ambiguous))
|
|
117
|
+
pool = 26 + (26 if not args.no_upper else 0) + (10 if not args.no_digits else 0) + \
|
|
118
|
+
(23 if not args.no_symbols else 0)
|
|
119
|
+
bits = args.length * math.log2(pool)
|
|
120
|
+
info(c("~%d bits of entropy" % bits, "gray"))
|
|
121
|
+
elif kind == "token":
|
|
122
|
+
for _ in range(args.n):
|
|
123
|
+
if args.format == "hex":
|
|
124
|
+
lines.append(secrets.token_hex(args.bytes))
|
|
125
|
+
elif args.format == "b64":
|
|
126
|
+
lines.append(secrets.token_urlsafe(args.bytes))
|
|
127
|
+
else:
|
|
128
|
+
lines.append(str(secrets.randbits(args.bytes * 8)))
|
|
129
|
+
elif kind == "id":
|
|
130
|
+
alphabet = args.alphabet or (string.ascii_letters + string.digits)
|
|
131
|
+
lines = ["".join(secrets.choice(alphabet) for _ in range(args.length)) for _ in range(args.n)]
|
|
132
|
+
elif kind == "lorem":
|
|
133
|
+
lines = [lorem(args.paragraphs, args.words)]
|
|
134
|
+
elif kind == "slug":
|
|
135
|
+
if not args.text:
|
|
136
|
+
raise DktError("Give some text to slugify.")
|
|
137
|
+
lines = [slugify(" ".join(args.text), args.sep)]
|
|
138
|
+
output_result("\n".join(lines), copy=args.copy)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def register(sub, common) -> None:
|
|
143
|
+
p = sub.add_parser("gen", aliases=["g"], parents=[common],
|
|
144
|
+
help=META[3], description="Generate common developer values.",
|
|
145
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
146
|
+
p.set_defaults(func=run, kind=None)
|
|
147
|
+
g = p.add_subparsers(dest="kind_", metavar="<kind>")
|
|
148
|
+
|
|
149
|
+
def add(name, help_, **kw):
|
|
150
|
+
sp = g.add_parser(name, help=help_)
|
|
151
|
+
sp.set_defaults(func=run, kind=name)
|
|
152
|
+
sp.add_argument("--copy", action="store_true", help="copy result to the clipboard")
|
|
153
|
+
return sp
|
|
154
|
+
|
|
155
|
+
sp = add("uuid", "UUID v4 (or v7)")
|
|
156
|
+
sp.add_argument("-n", type=int, default=1, help="how many")
|
|
157
|
+
sp.add_argument("--v7", action="store_true", help="time-sortable UUIDv7")
|
|
158
|
+
sp.add_argument("--upper", action="store_true")
|
|
159
|
+
sp.add_argument("--no-dashes", action="store_true")
|
|
160
|
+
sp = add("ulid", "sortable ULID")
|
|
161
|
+
sp.add_argument("-n", type=int, default=1)
|
|
162
|
+
sp = add("password", "strong random password")
|
|
163
|
+
sp.add_argument("-n", type=int, default=1)
|
|
164
|
+
sp.add_argument("-l", "--length", type=int, default=20)
|
|
165
|
+
sp.add_argument("--no-symbols", action="store_true")
|
|
166
|
+
sp.add_argument("--no-digits", action="store_true")
|
|
167
|
+
sp.add_argument("--no-upper", action="store_true")
|
|
168
|
+
sp.add_argument("--ambiguous", action="store_true", help="allow look-alike chars (0 O 1 l I)")
|
|
169
|
+
sp = add("token", "random secret / API key")
|
|
170
|
+
sp.add_argument("-n", type=int, default=1)
|
|
171
|
+
sp.add_argument("--bytes", type=int, default=32, help="random bytes (default 32)")
|
|
172
|
+
sp.add_argument("--format", choices=["hex", "b64", "int"], default="hex")
|
|
173
|
+
sp = add("id", "short random id")
|
|
174
|
+
sp.add_argument("-n", type=int, default=1)
|
|
175
|
+
sp.add_argument("-l", "--length", type=int, default=12)
|
|
176
|
+
sp.add_argument("--alphabet", help="custom alphabet (default a-zA-Z0-9)")
|
|
177
|
+
sp = add("lorem", "placeholder text")
|
|
178
|
+
sp.add_argument("-p", "--paragraphs", type=int, default=1)
|
|
179
|
+
sp.add_argument("-w", "--words", type=int, default=0, help="exact number of words instead")
|
|
180
|
+
sp = add("slug", "URL slug from text")
|
|
181
|
+
sp.add_argument("text", nargs="*")
|
|
182
|
+
sp.add_argument("--sep", default="-")
|
devkittools/tools/gx.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""dkt gx - everyday git shortcuts: pretty log, branch cleanup, undo, wip, sync, who."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from ..utils import DktError, color_enabled, c, confirm, git, ok, require_git_repo, resolve_folder, run_capture, table, warn, sym, kv
|
|
7
|
+
|
|
8
|
+
META = ("Git", "gx", "", "git helpers: log, branches (+prune), undo, wip, sync, who, amend")
|
|
9
|
+
|
|
10
|
+
EPILOG = """\
|
|
11
|
+
examples:
|
|
12
|
+
dkt gx log compact colored graph
|
|
13
|
+
dkt gx branches all local branches: last commit, merged?, ahead/behind
|
|
14
|
+
dkt gx branches --prune delete branches already merged into main (asks first)
|
|
15
|
+
dkt gx undo undo the last commit but keep your changes
|
|
16
|
+
dkt gx wip stage everything + commit as "wip"
|
|
17
|
+
dkt gx sync fetch --prune and show where you stand
|
|
18
|
+
dkt gx who who wrote how much
|
|
19
|
+
dkt gx amend add staged changes to the last commit (no message edit)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _repo(args, need_commits: bool = True):
|
|
24
|
+
folder = resolve_folder(args.folder)
|
|
25
|
+
require_git_repo(folder)
|
|
26
|
+
if need_commits and not git(folder, "rev-parse", "--verify", "--quiet", "HEAD", check=False).strip():
|
|
27
|
+
raise DktError("This repository has no commits yet.")
|
|
28
|
+
return folder
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_log(args) -> int:
|
|
32
|
+
folder = _repo(args)
|
|
33
|
+
fmt = "%C(bold yellow)%h%C(reset) %C(green)%ar%C(reset) %C(bold blue)%an%C(reset) %C(auto)%d%C(reset) %s"
|
|
34
|
+
import subprocess
|
|
35
|
+
subprocess.call(["git", "-C", str(folder), "log", "--graph", "--color=always" if color_enabled() else "--color=never", "-n", str(args.n),
|
|
36
|
+
"--pretty=format:" + fmt, "--all" if args.all else "--branches=*"], stdin=subprocess.DEVNULL)
|
|
37
|
+
print()
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_branch(folder) -> str:
|
|
42
|
+
for cand in ("main", "master", "develop"):
|
|
43
|
+
if git(folder, "rev-parse", "--verify", "--quiet", cand, check=False).strip():
|
|
44
|
+
return cand
|
|
45
|
+
return git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_branches(args) -> int:
|
|
49
|
+
folder = _repo(args)
|
|
50
|
+
base = _default_branch(folder)
|
|
51
|
+
current = git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
52
|
+
merged = set(b.strip().lstrip("* ") for b in git(folder, "branch", "--merged", base, "--format=%(refname:short)").splitlines())
|
|
53
|
+
out = git(folder, "for-each-ref", "refs/heads", "--sort=-committerdate",
|
|
54
|
+
"--format=%(refname:short)|%(committerdate:relative)|%(authorname)|%(upstream:track)|%(subject)")
|
|
55
|
+
rows, prunable = [], []
|
|
56
|
+
for line in out.splitlines():
|
|
57
|
+
name, when, author, track, subj = (line.split("|", 4) + [""] * 5)[:5]
|
|
58
|
+
flags = []
|
|
59
|
+
if name == current:
|
|
60
|
+
flags.append(c("current", "bgreen"))
|
|
61
|
+
if name in merged and name != base:
|
|
62
|
+
flags.append(c("merged", "gray"))
|
|
63
|
+
if name != current:
|
|
64
|
+
prunable.append(name)
|
|
65
|
+
if track:
|
|
66
|
+
flags.append(c(track.strip("[]"), "byellow"))
|
|
67
|
+
rows.append((c(name, "bold bcyan") if name == current else name, when, author[:16], " ".join(flags), subj[:44]))
|
|
68
|
+
table(["BRANCH", "LAST COMMIT", "AUTHOR", "STATE", "SUBJECT"], rows)
|
|
69
|
+
print(c("\n base branch: %s" % base, "gray"))
|
|
70
|
+
if args.prune:
|
|
71
|
+
prunable = [b for b in prunable if b != base]
|
|
72
|
+
if not prunable:
|
|
73
|
+
ok("no merged branches to delete")
|
|
74
|
+
return 0
|
|
75
|
+
print("\n will delete: " + ", ".join(prunable))
|
|
76
|
+
if confirm("Delete %d merged branches?" % len(prunable)):
|
|
77
|
+
for b in prunable:
|
|
78
|
+
git(folder, "branch", "-d", b)
|
|
79
|
+
ok("deleted %d branches" % len(prunable))
|
|
80
|
+
elif prunable:
|
|
81
|
+
print(c(" %d merged branch%s can be pruned: dkt gx branches --prune" % (len(prunable), "" if len(prunable) == 1 else "es"), "gray"))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_undo(args) -> int:
|
|
86
|
+
folder = _repo(args)
|
|
87
|
+
last = git(folder, "log", "-1", "--pretty=%h %s").strip()
|
|
88
|
+
if not last:
|
|
89
|
+
raise DktError("No commits to undo.")
|
|
90
|
+
print(" last commit: " + c(last, "bold"))
|
|
91
|
+
if not git(folder, "rev-parse", "--verify", "--quiet", "HEAD~1", check=False).strip():
|
|
92
|
+
raise DktError("That is the very first commit - nothing earlier to go back to.")
|
|
93
|
+
if args.yes or confirm("Undo it (changes stay staged)?", default=True):
|
|
94
|
+
git(folder, "reset", "--soft", "HEAD~1")
|
|
95
|
+
ok("commit undone - your changes are still staged")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_wip(args) -> int:
|
|
100
|
+
folder = _repo(args, need_commits=False)
|
|
101
|
+
git(folder, "add", "-A")
|
|
102
|
+
if not git(folder, "diff", "--cached", "--name-only").strip():
|
|
103
|
+
warn("nothing to commit")
|
|
104
|
+
return 0
|
|
105
|
+
git(folder, "commit", "-m", args.message, "--no-verify")
|
|
106
|
+
ok("committed: %s" % args.message)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_amend(args) -> int:
|
|
111
|
+
folder = _repo(args)
|
|
112
|
+
git(folder, "add", "-A") if args.all else None
|
|
113
|
+
git(folder, "commit", "--amend", "--no-edit")
|
|
114
|
+
ok("amended last commit")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_sync(args) -> int:
|
|
119
|
+
folder = _repo(args, need_commits=False)
|
|
120
|
+
code, _, err = run_capture(["git", "-C", str(folder), "fetch", "--all", "--prune"])
|
|
121
|
+
if code != 0:
|
|
122
|
+
raise DktError(err.strip() or "git fetch failed")
|
|
123
|
+
branch = git(folder, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
124
|
+
up = git(folder, "rev-parse", "--abbrev-ref", "@{u}", check=False).strip()
|
|
125
|
+
dirty = git(folder, "status", "--porcelain").strip().splitlines()
|
|
126
|
+
rows = [("branch", c(branch, "bold bcyan")), ("upstream", up or c("none", "yellow"))]
|
|
127
|
+
if up:
|
|
128
|
+
ahead, behind = git(folder, "rev-list", "--left-right", "--count", "HEAD...@{u}").split()
|
|
129
|
+
rows += [("ahead", ahead), ("behind", behind)]
|
|
130
|
+
rows.append(("working tree", c("clean", "green") if not dirty else c("%d changed file(s)" % len(dirty), "yellow")))
|
|
131
|
+
kv(rows)
|
|
132
|
+
if up and int(behind) > 0 and not dirty and args.pull:
|
|
133
|
+
git(folder, "pull", "--ff-only")
|
|
134
|
+
ok("fast-forwarded")
|
|
135
|
+
elif up and int(behind) > 0:
|
|
136
|
+
print(c("\n tip: dkt gx sync --pull to fast-forward", "gray"))
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_who(args) -> int:
|
|
141
|
+
folder = _repo(args)
|
|
142
|
+
out = git(folder, "shortlog", "-sne", "HEAD")
|
|
143
|
+
rows = []
|
|
144
|
+
mx = 1
|
|
145
|
+
parsed = []
|
|
146
|
+
for line in out.splitlines():
|
|
147
|
+
n, _, who = line.strip().partition("\t")
|
|
148
|
+
if n.isdigit():
|
|
149
|
+
parsed.append((int(n), who))
|
|
150
|
+
if parsed:
|
|
151
|
+
mx = parsed[0][0]
|
|
152
|
+
from ..utils import bar
|
|
153
|
+
for n, who in parsed[:args.top]:
|
|
154
|
+
rows.append((str(n), c(bar(n, mx, 14), "bcyan"), who))
|
|
155
|
+
table(["COMMITS", "", "AUTHOR"], rows)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def register(sub, common) -> None:
|
|
160
|
+
p = sub.add_parser("gx", parents=[common], help=META[3], description="Git quality-of-life shortcuts.",
|
|
161
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
162
|
+
p.set_defaults(func=lambda a: (p.print_help() or 0))
|
|
163
|
+
g = p.add_subparsers(dest="sub", metavar="<action>")
|
|
164
|
+
|
|
165
|
+
def add(name, fn, help_):
|
|
166
|
+
sp = g.add_parser(name, help=help_)
|
|
167
|
+
sp.add_argument("-f", "--folder", metavar="PATH")
|
|
168
|
+
sp.set_defaults(func=fn)
|
|
169
|
+
return sp
|
|
170
|
+
sp = add("log", cmd_log, "compact colored commit graph")
|
|
171
|
+
sp.add_argument("-n", type=int, default=25)
|
|
172
|
+
sp.add_argument("--all", action="store_true", help="include remotes and tags")
|
|
173
|
+
sp = add("branches", cmd_branches, "branch overview, optional prune")
|
|
174
|
+
sp.add_argument("--prune", action="store_true", help="delete merged local branches")
|
|
175
|
+
sp = add("undo", cmd_undo, "undo last commit, keep changes")
|
|
176
|
+
sp.add_argument("-y", "--yes", action="store_true")
|
|
177
|
+
sp = add("wip", cmd_wip, "stage all and commit as WIP")
|
|
178
|
+
sp.add_argument("-m", "--message", default="wip")
|
|
179
|
+
sp = add("amend", cmd_amend, "amend the last commit without editing its message")
|
|
180
|
+
sp.add_argument("-a", "--all", action="store_true", help="stage everything first")
|
|
181
|
+
sp = add("sync", cmd_sync, "fetch + prune and show ahead/behind")
|
|
182
|
+
sp.add_argument("--pull", action="store_true", help="fast-forward if behind and clean")
|
|
183
|
+
sp = add("who", cmd_who, "commits per author")
|
|
184
|
+
sp.add_argument("--top", type=int, default=10)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""dkt hash (h) - hash text or files, and verify checksums."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..utils import DktError, c, human_size, info, read_input, ok, fail, sym
|
|
10
|
+
|
|
11
|
+
META = ("Generate & Convert", "hash", "h", "hash text/files, verify checksums, HMAC")
|
|
12
|
+
|
|
13
|
+
ALGOS = ["md5", "sha1", "sha256", "sha512", "sha3_256", "blake2b"]
|
|
14
|
+
LEN_TO_ALGO = {32: "md5", 40: "sha1", 64: "sha256", 128: "sha512"}
|
|
15
|
+
|
|
16
|
+
EPILOG = """\
|
|
17
|
+
examples:
|
|
18
|
+
dkt hash "hello world" sha256 of text
|
|
19
|
+
dkt hash -f setup.exe -a md5 sha256 several algorithms for one file
|
|
20
|
+
dkt hash -f big.iso --verify 3a7bd3e2... algorithm auto-detected from length
|
|
21
|
+
dkt hash "msg" --hmac secretkey HMAC-SHA256
|
|
22
|
+
echo -n hi | dkt hash -a all
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hash_file(path: Path, algos):
|
|
27
|
+
hs = {a: hashlib.new(a) for a in algos}
|
|
28
|
+
with open(str(path), "rb") as f:
|
|
29
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
30
|
+
for h in hs.values():
|
|
31
|
+
h.update(chunk)
|
|
32
|
+
return {a: h.hexdigest() for a, h in hs.items()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(args) -> int:
|
|
36
|
+
algos = ALGOS if "all" in args.algo else args.algo
|
|
37
|
+
if args.verify:
|
|
38
|
+
want = args.verify.strip().lower()
|
|
39
|
+
if len(algos) != 1 or args.algo == ["sha256"] and len(want) in LEN_TO_ALGO:
|
|
40
|
+
if len(want) in LEN_TO_ALGO:
|
|
41
|
+
algos = [LEN_TO_ALGO[len(want)]]
|
|
42
|
+
results = [] # (label, {algo: digest})
|
|
43
|
+
if args.hmac:
|
|
44
|
+
if args.file:
|
|
45
|
+
raise DktError("--hmac works on text, not files.")
|
|
46
|
+
data = read_input(args.text, None, args.paste, "text").encode("utf-8")
|
|
47
|
+
a = algos[0]
|
|
48
|
+
results.append(("text", {"hmac-" + a: hmac.new(args.hmac.encode(), data, a).hexdigest()}))
|
|
49
|
+
elif args.file:
|
|
50
|
+
for f in args.file:
|
|
51
|
+
p = Path(f).expanduser()
|
|
52
|
+
if not p.is_file():
|
|
53
|
+
raise DktError("File not found: %s" % p)
|
|
54
|
+
label = "%s (%s)" % (p.name, human_size(p.stat().st_size))
|
|
55
|
+
results.append((label, hash_file(p, algos)))
|
|
56
|
+
else:
|
|
57
|
+
data = read_input(args.text, None, args.paste, "text").encode("utf-8")
|
|
58
|
+
d = {}
|
|
59
|
+
for a in algos:
|
|
60
|
+
h = hashlib.new(a)
|
|
61
|
+
h.update(data)
|
|
62
|
+
d[a] = h.hexdigest()
|
|
63
|
+
results.append(("text", d))
|
|
64
|
+
|
|
65
|
+
multi = len(results) > 1 or sum(len(d) for _, d in results) > 1
|
|
66
|
+
for label, d in results:
|
|
67
|
+
if multi:
|
|
68
|
+
info(c(label, "bold"))
|
|
69
|
+
for a, digest in d.items():
|
|
70
|
+
print(("%-12s %s" % (a, digest)) if multi else digest)
|
|
71
|
+
if args.verify:
|
|
72
|
+
want = args.verify.strip().lower()
|
|
73
|
+
hit = [a for _, d in results for a, dig in d.items() if dig == want]
|
|
74
|
+
if hit:
|
|
75
|
+
ok("checksum matches (%s)" % hit[0])
|
|
76
|
+
return 0
|
|
77
|
+
fail("checksum does NOT match")
|
|
78
|
+
return 1
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register(sub, common) -> None:
|
|
83
|
+
p = sub.add_parser("hash", aliases=["h"], parents=[common], help=META[3],
|
|
84
|
+
description="Hash text, stdin or files with common algorithms.",
|
|
85
|
+
epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
86
|
+
p.add_argument("text", nargs="?", help="text to hash (or pipe stdin)")
|
|
87
|
+
p.add_argument("-f", "--file", nargs="+", metavar="FILE", help="hash file(s) instead of text")
|
|
88
|
+
p.add_argument("-a", "--algo", nargs="+", default=["sha256"],
|
|
89
|
+
choices=ALGOS + ["all"], help="algorithm(s), default sha256")
|
|
90
|
+
p.add_argument("--verify", metavar="HASH", help="compare against an expected hash")
|
|
91
|
+
p.add_argument("--hmac", metavar="KEY", help="compute an HMAC with this key")
|
|
92
|
+
p.add_argument("--paste", action="store_true", help="read text from the clipboard")
|
|
93
|
+
p.set_defaults(func=run)
|