forked from disruptek/bump
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdamp.nim
632 lines (545 loc) · 19.6 KB
/
damp.nim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
import std/os
import std/options
import std/osproc
import std/strutils
import std/strformat
import std/times
import std/nre
from std/macros import nil
import cutelog
type
Version* = tuple
major: uint
minor: uint
patch: uint
Target* = tuple
repo: string
package: string
ext: string
SearchResult* = tuple
message: string
found: Option[Target]
const
dotNimble = "".addFileExt("nimble")
defaultExts = @["nimble"]
logLevel =
when defined(debug):
lvlDebug
elif defined(release):
lvlNotice
elif defined(danger):
lvlNotice
else:
lvlInfo
template crash(why: string) =
## a good way to exit bump()
error why
return 1
proc `$`(target: Target): string =
result = target.repo / target.package & target.ext
proc `$`(ver: Version): string =
result = &"{ver.major}.{ver.minor}.{ver.patch}"
proc isNamedLikeDotNimble(dir: string; file: string): bool =
## true if it the .nimble filename (minus ext) matches the directory
if dir == "" or file == "":
return
if not file.endsWith(dotNimble):
return
result = dir.lastPathPart == file.changeFileExt("")
proc safeCurrentDir(): string =
when nimvm:
result = os.getEnv("PWD", os.getEnv("CD", ""))
else:
result = getCurrentDir()
proc newTarget(path: string): Target =
let splat = path.splitFile
result = (repo: splat.dir, package: splat.name, ext: splat.ext)
proc findTargetWith(dir: string; cwd: proc (): string; target = "";
ascend = true; extensions = defaultExts): SearchResult =
## locate one, and only one, nimble file to work upon; dir is where
## to start looking, target is a .nimble or package name
# viable selections are limited to the target and possible extensions
var
viable: seq[string]
exts: seq[string]
# an empty extension is acceptable in the extensions argument
for extension in extensions.items:
# create mypackage.nimble, mypackage.nimble-link
viable.add target.addFileExt(extension)
# create .nimble, .nimble-link
exts.add "".addFileExt(extension)
# search the directory for a .nimble file
for component, filename in walkDir(dir):
if component notin {pcFile, pcLinkToFile}:
continue
let splat = splitFile(filename)
# first, look at the whole filename for the purposes of matching
if target != "":
if filename.extractFilename notin viable:
continue
# otherwise, fall back to checking for suitable extension
elif splat.ext notin exts:
continue
# a 2nd .nimble overrides the first if it matches the directory name
if result.found.isSome:
# if it also isn't clearly the project's .nimble, keep looking
if not isNamedLikeDotNimble(dir, filename):
result = (message:
&"found `{result.found.get}` and `{filename}` in `{dir}`",
found: none(Target))
continue
# we found a .nimble; let's set our result and keep looking for a 2nd
result = (message: &"found target in `{dir}` given `{target}`",
found: newTarget(filename).some)
# this appears to be the best .nimble; let's stop looking here
if isNamedLikeDotNimble(dir, filename):
break
# we might be good to go, here
if result.found.isSome or not ascend:
return
# otherwise, maybe we can recurse up the directory tree.
# if our dir is `.`, then we might want to shadow it with a
# full current dir using the supplied proc
let dir = if dir == ".": cwd() else: dir
# if we're already at a root, i guess we're done
if dir.isRootDir:
return (message: "", found: none(Target))
# else let's see if we have better luck in a parent directory
var
refined = findTargetWith(dir.parentDir, cwd, target = target)
# return the refinement if it was successful,
if refined.found.isSome:
return refined
# or if the refinement yields a superior error message
if refined.message != "" and result.message == "":
return refined
proc findTarget(dir: string; target = ""): SearchResult =
## locate one, and only one, nimble file to work upon; dir is where
## to start looking, target is a .nimble or package name
result = findTargetWith(dir, safeCurrentDir, target = target)
proc createTemporaryFile(prefix: string; suffix: string): string =
## it SHOULD create the file, but so far, it only returns the filename
let temp = getTempDir()
result = temp / "bump-" & $getCurrentProcessId() & "-" & prefix & suffix
proc isValid(ver: Version): bool =
## true if the version seems legit
result = ver.major > 0'u or ver.minor > 0'u or ver.patch > 0'u
proc parseVersion(nimble: string): Option[Version] =
## try to parse a version from any line in a .nimble;
## safe to use at compile-time
for line in nimble.splitLines:
if not line.startsWith("version"):
continue
let
fields = line.split('=')
if fields.len != 2:
continue
var
dotted = fields[1].replace("\"").strip.split('.')
case dotted.len:
of 3: discard
of 2: dotted.add "0"
else:
continue
try:
result = (major: dotted[0].parseUInt,
minor: dotted[1].parseUInt,
patch: dotted[2].parseUInt).some
except ValueError:
discard
proc dateVer(dt: DateTime, extended = false): Version =
if extended:
(major: dt.year.uint, minor: 100 * dt.month.uint + dt.monthDay.uint, patch: 0.uint)
else:
(major: dt.year.uint, minor: dt.month.uint, patch: dt.monthDay.uint)
proc withCrazySpaces(version: Version; line = ""): string =
## insert a new version into a line which may have "crazy spaces"
while line != "":
let
verex = line.match re(r"""^version(\s*)=(\s*)"\d+.\d+.\d+"(\s*)""")
if not verex.isSome:
break
let
cap = verex.get.captures.toSeq
(c1, c2, c3) = (cap[0].get, cap[1].get, cap[2].get)
result = &"""version{c1}={c2}"{version}"{c3}"""
return
result = &"""version = "{version}""""
proc capture(exe: string; args: seq[string];
options: set[ProcessOption]): tuple[output: string; ok: bool] =
## capture output of a command+args and indicate apparent success
var
command = findExe(exe)
if command == "":
result = (output: &"unable to find executable `{exe}` in path", ok: false)
warn result.output
return
# we apparently need to escape arguments when using this subprocess form
command &= " " & quoteShellCommand(args)
debug command # let's take a look at those juicy escape sequences
# run it and get the output to construct our return value
let (output, exit) = execCmdEx(command, options)
result = (output: output, ok: exit == 0)
# provide a simplified summary at appropriate logging levels
let
ran = exe & " " & args.join(" ")
if result.ok:
info ran
else:
notice ran
proc capture(exe: string; args: seq[string]): tuple[output: string; ok: bool] =
## find and run a given executable with the given arguments;
## the result includes stdout/stderr and a true value if it seemed to work
result = capture(exe, args, {poStdErrToStdOut, poDaemon, poEvalCommand})
proc run(exe: string; args: varargs[string]): bool =
## find and run a given executable with the given arguments;
## the result is true if it seemed to work
var
arguments: seq[string]
for n in args:
arguments.add n
let
caught = capture(exe, arguments)
if not caught.ok:
notice caught.output
result = caught.ok
proc appearsToBeMasterBranch(): Option[bool] =
## try to determine if we're on the `master`/`main` branch
var
caught = capture("git", @["branch", "--show-current"])
if caught.ok:
result = caught.output.contains(re"(*ANYCRLF)(?m)(?x)^master|main$").some
else:
caught = capture("git", @["branch"])
if not caught.ok:
notice caught.output
return
result = caught.output.contains(re"(*ANYCRLF)(?m)(?x)^master|main$").some
debug &"appears to be master/main branch? {result.get}"
proc fetchTagList(): Option[string] =
## simply retrieve the tags as a string; attempt to use the
## later git option to sort the result by version
var
caught = capture("git", @["tag", "--sort=version:refname"])
if not caught.ok:
caught = capture("git", @["tag", "--list"])
if not caught.ok:
notice caught.output
return
result = caught.output.strip.some
proc lastTagInTheList(tagList: string): string =
## lazy way to get a tag from the list, whatfer mimicking its V form
let
verex = re("(*ANYCRLF)(?i)(?m)^v?\\.?\\d+\\.\\d+\\.\\d+$")
for match in tagList.findAll(verex):
result = match
if result == "":
raise newException(ValueError, "could not identify a sane tag")
debug &"the last tag in the list is `{result}`"
proc taggedAs(version: Version; tagList: string): Option[string] =
## try to fetch a tag that appears to match a given version
let
escaped = replace($version, ".", "\\.")
verex = re("(*ANYCRLF)(?i)(?m)^v?\\.?" & escaped & "$")
for match in tagList.findAll(verex):
if result.isSome:
debug &"got more than one tag for version {version}:"
debug &"`{result.get}` and `{match}`"
result = none(string)
break
result = match.some
if result.isSome:
debug &"version {version} was tagged as {result.get}"
proc allTagsAppearToStartWithV(tagList: string): bool =
## try to determine if all of this project's tags start with a `v`
let
splat = tagList.splitLines(keepEol = false)
verex = re("(?i)(?x)^v\\.?\\d+\\.\\d+\\.\\d+$")
# if no tags exist, the result is false, right? RIGHT?
if splat.len == 0:
return
for line in splat:
if not line.contains(verex):
debug &"found a tag `{line}` which doesn't use `v`"
return
result = true
debug &"all tags appear to start with `v`"
proc shouldSearch(folder: string; nimble: string):
Option[tuple[dir: string; file: string]] =
## given a folder and nimble file (which may be empty), find the most useful
## directory and target filename to search for. this is a little convoluted
## because we're trying to replace the function of three options in one proc.
var
dir, file: string
if folder == "":
if nimble != "":
# there's no folder specified, so if a nimble was provided,
# split it into a directory and file for the purposes of search
(dir, file) = splitPath(nimble)
# if the directory portion is empty, search the current directory
if dir == "":
dir = $CurDir # should be correct regardless of os
else:
dir = folder
file = nimble
# by now, we at least know where we're gonna be looking
if not dirExists(dir):
warn &"`{dir}` is not a directory"
return
# try to look for a .nimble file just in case
# we can identify it quickly and easily here
while file != "" and not fileExists(dir / file):
if file.endsWith(dotNimble):
# a file was specified but we cannot find it, even given
# a reasonable directory and the addition of .nimble
warn &"`{dir}/{file}` does not exist"
return
file &= dotNimble
debug &"should search `{dir}` for `{file}`"
result = (dir: dir, file: file).some
proc pluckVAndDot(input: string): string =
## return any `V` or `v` prefix, perhaps with an existing `.`
if input.len == 0 or input[0] notin {'V', 'v'}:
result = ""
elif input[1] == '.':
result = input[0 .. 1]
else:
result = input[0 .. 0]
proc composeTag(last: Version; next: Version; v = false; tags = ""):
Option[string] =
## invent a tag given last and next version, magically adding any
## needed `v` prefix. fetches tags if a tag list isn't supplied.
var
tag, list: string
# get the list of tags as a string; boy, i love strings
if tags != "":
list = tags
else:
let
tagList = fetchTagList()
if tagList.isNone:
error &"unable to retrieve tags"
return
list = tagList.get
let
veeish = allTagsAppearToStartWithV(list)
lastTag = last.taggedAs(list)
# first, see what the last version was tagged as
if lastTag.isSome:
if lastTag.get.toLowerAscii.startsWith("v"):
# if it starts with `v`, then use `v` similarly
tag = lastTag.get.pluckVAndDot & $next
elif v:
# it didn't start with `v`, but the user wants `v`
tag = "v" & $next
else:
# it didn't start with `v`, so neither should this tag
tag = $next
# otherwise, see if all the prior tags use `v`
elif veeish:
# if all the tags start with `v`, it's a safe bet that we want `v`
# pick the last tag and match its `v` syntax
tag = lastTagInTheList(list).pluckVAndDot & $next
# no history to speak of, but the user asked for `v`; give them `v`
elif v:
tag = "v" & $next
# no history, didn't ask for `v`, so please just don't use `v`!
else:
tag = $next
result = tag.some
debug &"composed the tag `{result.get}`"
proc bump(release = false; extended = false;
dry_run = false; folder = ""; nimble = ""; log_level = logLevel;
commit = false; v = false; manual = ""; message: seq[string]): int =
## the entry point from the cli
var
target: Target
next: Version
last: Option[Version]
# user's choice, our default
setLogFilter(log_level)
if folder != "":
warn "the --folder option is deprecated; please use --nimble instead"
# parse and assign a version number manually provided by the user
if manual != "":
# use our existing parser for consistency
let future = parseVersion(&"""version = "{manual}"""")
if future.isNone or not future.get.isValid:
crash &"unable to parse supplied version `{manual}`"
next = future.get
debug &"user-specified next version as `{next}`"
# take a stab at whether our .nimble file search might be illegitimate
let search = shouldSearch(folder, nimble)
if search.isNone:
# uh oh; it's not even worth attempting a search
crash &"nothing to bump"
# find the targeted .nimble file
let
sought = findTarget(search.get.dir, target = search.get.file)
if sought.found.isNone:
# emit any available excuse as to why we couldn't find .nimble
if sought.message != "":
warn sought.message
crash &"couldn't pick a {dotNimble} from `{search.get.dir}/{search.get.file}`"
else:
debug sought.message
target = sought.found.get
# if we're not on the master/main branch, let's just bail for now
let
branch = appearsToBeMasterBranch()
if branch.isNone:
crash "uh oh; i cannot tell if i'm on the master/main branch"
elif not branch.get:
crash "i'm afraid to modify any branch that isn't master/main"
else:
debug "good; this appears to be the master/main branch"
# make a temp file in an appropriate spot, with a significant name
let
temp = createTemporaryFile(target.package, dotNimble)
debug &"writing {temp}"
# but remember to remove the temp file later
defer:
debug &"removing {temp}"
if not tryRemoveFile(temp):
warn &"unable to remove temporary file `{temp}`"
block writing:
# open our temp file for writing
var
writer = temp.open(fmWrite)
# but remember to close the temp file in any event
defer:
writer.close
for line in lines($target):
if not line.contains(re"^version\s*="):
writer.writeLine line
continue
# parse the current version number
last = line.parseVersion
if last.isNone:
crash &"unable to parse version from `{line}`"
else:
debug "current version is", last.get
# if we haven't set the new version yet, bump the version number
if not next.isValid:
var bumped = now().dateVer(extended)
if extended:
if (bumped.major, bumped.minor) == (last.get.major, last.get.minor):
bumped.patch = last.get.patch.succ
debug "next version is", bumped
else:
if bumped == last.get:
crash "there has already been a new version today, please wait until tomorrow"
else:
debug "next version is", bumped
next = bumped
# make a subtle edit to the version string and write it out
writer.writeLine next.withCrazySpaces(line)
# for sanity, make sure we were able to parse the previous version
if last.isNone:
crash &"couldn't find a version statement in `{target}`"
# and check again to be certain that our next version is valid
if not next.isValid:
crash &"unable to calculate the next version; `{next}` invalid"
# move to the repo so we can do git operations
debug "changing directory to", target.repo
setCurrentDir(target.repo)
# compose a new tag
let
composed = composeTag(last.get, next, v = v)
if composed.isNone:
crash "i can't safely guess at enabling `v`; try a manual tag first?"
let
tag = composed.get
# make a git commit message
var msg = tag
if message.len > 0:
msg &= ": " & message.join(" ")
# cheer
fatal &"🎉{msg}"
if dry_run:
debug "dry run and done"
return
# copy the new .nimble over the old one
try:
debug &"copying {temp} over {target}"
copyFile(temp, $target)
except Exception as e:
discard e # noqa 😞
crash &"failed to copy `{temp}` to `{target}`: {e.msg}"
# try to do some git operations
block nimgitsfu:
# commit just the .nimble file, or the whole repository
let
committee = if commit: target.repo else: $target
if not run("git", "commit", "-m", msg, committee):
break
# if a message exists, omit the tag from the message
if message.len > 0:
msg = message.join(" ")
# tag the commit with the new version and message
if not run("git", "tag", "-a", "-m", msg, tag):
break
# push the commits
if not run("git", "push"):
break
# push the tags
if not run("git", "push", "--tags"):
break
# we might want to use hub to mark a github release
if release:
if not run("hub", "release", "create", "-m", msg, tag):
break
# celebrate
fatal "🍻bumped"
return 0
# hang our head in shame
fatal "🐼nimgitsfu fail"
return 1
proc projectVersion(hint = ""): Option[Version] {.compileTime.} =
## try to get the version from the current (compile-time) project
let
target = findTargetWith(macros.getProjectPath(), safeCurrentDir, hint)
if target.found.isNone:
macros.warning target.message
macros.error &"provide the name of your project, minus {dotNimble}"
var
nimble = staticRead $target.found.get
if nimble == "":
macros.error &"missing/empty {dotNimble}; what version is this?!"
result = parseVersion(nimble)
when isMainModule:
import cligen
let
logger = newCuteConsoleLogger()
addHandler(logger)
const logo = """
_
__| | __ _ _ __ ___ _ __
/ _` |/ _` | '_ ` _ \| '_ \
| (_| | (_| | | | | | | |_) |
\__,_|\__,_|_| |_| |_| .__/
|_|
Set the version of a nimble package to the current date, tag it, and push it via git
Usage:
damp [optional-params] [message: string...]
"""
# find the version of bump itself, whatfer --version reasons
const
version = projectVersion()
if version.isSome:
clCfg.version = $version.get
else:
clCfg.version = "(unknown version)"
dispatchCf bump, cmdName = "damp", cf = clCfg, noHdr = true,
usage = logo & "Options(opt-arg sep :|=|spc):\n$options",
help = {
"dry-run": "just report the projected version",
"commit": "also commit any other unstaged changes",
"v": "prefix the version tag with an ugly `v`",
"extended": "use the format year.monthday.patch instead of year.month.day",
"nimble": "specify the nimble file to modify",
"folder": "specify the location of the nimble file",
"release": "also use `hub` to issue a GitHub release",
"log-level": "specify Nim logging level",
"manual": "manually set the new version to #.#.#",
}