1 /** 2 Copyright: Copyright (c) 2020, Joakim Brännström. All rights reserved. 3 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost Software License 1.0) 4 Author: Joakim Brännström (joakim.brannstrom@gmx.com) 5 */ 6 module watchexec; 7 8 import core.thread : Thread; 9 import logger = std.experimental.logger; 10 import std.algorithm : filter, map, joiner; 11 import std.array : array, empty; 12 import std.conv : text; 13 import std.datetime : Duration; 14 import std.datetime : dur, Clock; 15 import std.exception : collectException; 16 import std.format : format; 17 18 import colorlog; 19 import my.filter; 20 import my.path; 21 import my.fswatch : Monitor, MonitorResult; 22 import my.filter : GlobFilter; 23 24 version (unittest) { 25 } else { 26 int main(string[] args) { 27 confLogger(VerboseMode.info); 28 29 auto conf = parseUserArgs(args); 30 31 confLogger(conf.global.verbosity); 32 logger.trace(conf); 33 34 if (conf.global.help) 35 return cliHelp(conf); 36 return cli(conf); 37 } 38 } 39 40 private: 41 42 immutable notifySendCmd = "notify-send"; 43 44 immutable defaultExclude = [ 45 "*/.DS_Store", "*.py[co]", "*/#*#", "*/.#*", "*/.*.kate-swp", "*/.*.sw?", 46 "*/.*.sw?x", "*/.git/*" 47 ]; 48 49 int cliHelp(AppConfig conf) { 50 conf.printHelp; 51 return 0; 52 } 53 54 int cli(AppConfig conf) { 55 import std.stdio : write, writeln; 56 import my.fswatch : ContentEvents, MetadataEvents; 57 import proc; 58 59 if (conf.global.paths.empty) { 60 logger.error("No directories specified to watch"); 61 return 1; 62 } 63 if (conf.global.command.empty) { 64 logger.error("No command to execute specified"); 65 return 1; 66 } 67 68 auto defaultFilter = GlobFilter(conf.global.include, conf.global.exclude); 69 auto recurseFilter = conf.global.useVcsIgnore 70 ? parseGitIgnoreRecursive(conf.global.include, conf.global.paths) : null; 71 72 logger.infof("command to execute on change: %-(%s %)", conf.global.command); 73 74 const cmd = () { 75 if (conf.global.useShell) 76 logger.info("--shell is deprecated"); 77 return ["/bin/sh", "-c", format!"%-(%s %)"(conf.global.command)]; 78 }(); 79 80 auto handleExitStatus = HandleExitStatus(conf.global.useNotifySend); 81 82 if (conf.global.oneShotMode) 83 return cliOneshot(conf, cmd, handleExitStatus, defaultFilter); 84 85 logger.infof("watching: %s", conf.global.paths); 86 logger.info("starting"); 87 88 auto monitor = Monitor(conf.global.paths, defaultFilter, recurseFilter, 89 conf.global.watchMetadata ? (ContentEvents | MetadataEvents) : ContentEvents); 90 91 MonitorResult[] buildAndExecute(MonitorResult[] eventFiles) { 92 string[string] env; 93 MonitorResult[] rval; 94 95 if (conf.global.setEnv) { 96 env["WATCHEXEC_EVENT"] = eventFiles.map!(a => format!"%s:%s"(a.kind, 97 a.path)).joiner(";").text; 98 } 99 100 auto p = spawnProcess(cmd, env).sandbox.timeout(conf.global.timeout) 101 .rcKill(conf.global.signal); 102 103 if (conf.global.restart) { 104 while (!p.tryWait && rval.empty) { 105 rval = monitor.wait(10.dur!"msecs"); 106 } 107 108 if (rval.empty) { 109 handleExitStatus.exitStatus(p.status); 110 } else { 111 p.kill(conf.global.signal); 112 p.wait; 113 logger.info("restarting command".color(Color.yellow)); 114 } 115 } else { 116 p.wait; 117 handleExitStatus.exitStatus(p.status); 118 } 119 120 if (conf.global.clearEvents) { 121 // the events can fire a bit late when e.g. writing to an NFS mount 122 // point. 123 monitor.collect(10.dur!"msecs"); 124 } 125 126 return rval; 127 } 128 129 MonitorResult[] eventFiles; 130 131 if (!conf.global.postPone) { 132 try { 133 eventFiles = buildAndExecute(null); 134 } catch (Exception e) { 135 logger.error(e.msg); 136 return 1; 137 } 138 } 139 140 while (true) { 141 if (eventFiles.empty) { 142 eventFiles = monitor.wait(1000.dur!"weeks"); 143 } 144 145 foreach (changed; eventFiles) { 146 logger.tracef("%s changed", changed); 147 } 148 149 if (!eventFiles.empty) { 150 if (conf.global.debounce != Duration.zero) { 151 eventFiles ~= monitor.collect(conf.global.debounce); 152 } 153 154 if (conf.global.clearScreen) { 155 write("\033c"); 156 } 157 158 try { 159 eventFiles = buildAndExecute(eventFiles); 160 } catch (Exception e) { 161 logger.error(e.msg); 162 return 1; 163 } 164 165 } 166 } 167 } 168 169 int cliOneshot(AppConfig conf, const string[] cmd, 170 HandleExitStatus handleExitStatus, GlobFilter defaultFilter) { 171 import std.algorithm : map, filter, joiner; 172 import std.datetime : Clock; 173 import std.file : exists, readText, isDir, isFile, timeLastModified, getSize; 174 import std.parallelism : taskPool, task; 175 import std.stdio : File; 176 import std.typecons : tuple, Tuple; 177 import my.file : existsAnd; 178 import my.optional; 179 import watchexec_internal.oneshot; 180 181 static bool update(ref FileDb db, ref FileDb newDb, ref OneShotFile f) { 182 const changed = db.isChanged(f); 183 184 if (changed) 185 newDb.add(f); 186 else if (auto v = f.path in db.files) 187 newDb.add(*v); // avoid checksum calculation 188 else 189 newDb.add(f); 190 return changed; 191 } 192 193 auto db = () { 194 if (!exists(conf.global.jsonDb)) 195 return FileDb.init; 196 197 try { 198 logger.infof("Reading %s", conf.global.jsonDb); 199 return fromJson(readText(conf.global.jsonDb)); 200 } catch (Exception e) { 201 } 202 203 return FileDb.init; 204 }(); 205 206 bool isChanged; 207 FileDb newDb; 208 209 static OneShotFile[] runOneshot(Tuple!(AbsolutePath, GlobFilter) data) { 210 return OneShotRange(data[0], data[1]).filter!(a => a.hasValue) 211 .map!(a => a.orElse(OneShotFile.init)) 212 .array; 213 } 214 215 try { 216 foreach (f; taskPool.amap!runOneshot(conf.global 217 .paths 218 .filter!(existsAnd!isDir) 219 .map!(a => tuple(a, defaultFilter)) 220 .array).joiner) { 221 const changed = update(db, newDb, f); 222 isChanged = isChanged || changed; 223 } 224 225 foreach (fname; conf.global.paths.filter!isFile) { 226 auto f = OneShotFile(AbsolutePath(fname), TimeStamp(timeLastModified(fname.toString, 227 Clock.currTime).toUnixTime), FileSize(getSize(fname))); 228 const changed = update(db, newDb, f); 229 isChanged = isChanged || changed; 230 } 231 } catch (Exception e) { 232 logger.info(e.msg).collectException; 233 } 234 235 int exitStatus; 236 if (isChanged) { 237 import proc; 238 239 auto saveDb = task(() { 240 try { 241 File(conf.global.jsonDb, "w").write(toJson(newDb)); 242 } catch (Exception e) { 243 logger.info(e.msg).collectException; 244 } 245 }); 246 saveDb.executeInNewThread; 247 248 auto p = spawnProcess(cmd).sandbox.timeout(conf.global.timeout).rcKill(conf.global.signal); 249 p.wait; 250 exitStatus = p.status; 251 252 saveDb.yieldForce; 253 254 logger.trace("old database: ", db); 255 logger.trace("new database: ", newDb); 256 } 257 258 handleExitStatus.exitStatus(exitStatus); 259 return exitStatus; 260 } 261 262 struct HandleExitStatus { 263 bool useNotifySend; 264 string notifyMsg; 265 266 this(string notifyMsg) { 267 this.useNotifySend = !notifyMsg.empty; 268 this.notifyMsg = notifyMsg; 269 } 270 271 void exitStatus(int code) { 272 import std.conv : to; 273 import std.process : spawnProcess, wait; 274 275 immutable msgExitStatus = "exit status"; 276 immutable msgOk = "✓"; 277 immutable msgNok = "✗"; 278 279 if (useNotifySend) { 280 auto msg = () { 281 if (code == 0) 282 return format!"%s %s %s\n%s"(msgOk, msgExitStatus, code, notifyMsg); 283 return format!"%s %s %s\n%s"(msgNok, msgExitStatus, code, notifyMsg); 284 }(); 285 286 spawnProcess([ 287 notifySendCmd, "-u", "normal", "-t", "3000", "-a", "watchexec", 288 msg 289 ]).wait; 290 } 291 292 auto msg = () { 293 if (code == 0) 294 return format!"%s %s"(msgOk, msgExitStatus.color(Color.green)); 295 return format!"%s %s"(msgNok, msgExitStatus.color(Color.red)); 296 }(); 297 298 logger.infof("%s %s", msg, code); 299 } 300 } 301 302 struct AppConfig { 303 static import std.getopt; 304 305 static struct Global { 306 import core.sys.posix.signal : SIGKILL; 307 308 std.getopt.GetoptResult helpInfo; 309 VerboseMode verbosity; 310 bool help = true; 311 312 AbsolutePath[] paths; 313 Duration debounce; 314 Duration timeout; 315 bool useVcsIgnore; 316 bool clearEvents; 317 bool clearScreen; 318 bool oneShotMode; 319 bool postPone; 320 bool restart; 321 bool setEnv; 322 bool useShell; 323 bool watchMetadata; 324 int signal = SIGKILL; 325 string jsonDb = "watchexec_db.json"; 326 string progName; 327 string useNotifySend; 328 string[] command; 329 330 string[] include; 331 string[] exclude; 332 } 333 334 Global global; 335 336 void printHelp() { 337 std.getopt.defaultGetoptPrinter(format( 338 "Execute commands when watched files change\nusage: %s [options] -- <command>\n\noptions:", 339 global.progName), global.helpInfo.options); 340 } 341 } 342 343 AppConfig parseUserArgs(string[] args) { 344 import logger = std.experimental.logger; 345 import std.algorithm : countUntil, map; 346 import std.path : baseName; 347 import std.traits : EnumMembers; 348 import my.file : existsAnd, isFile, whichFromEnv; 349 static import std.getopt; 350 351 AppConfig conf; 352 conf.global.progName = args[0].baseName; 353 354 try { 355 const idx = countUntil(args, "--"); 356 if (args.length > 1 && idx > 1) { 357 conf.global.command = args[idx + 1 .. $]; 358 args = args[0 .. idx]; 359 } 360 361 bool clearEvents; 362 bool noDefaultIgnore; 363 bool noVcsIgnore; 364 string[] include; 365 string[] monitorExtensions; 366 string[] paths; 367 uint debounce = 200; 368 uint timeout = 3600; 369 // dfmt off 370 conf.global.helpInfo = std.getopt.getopt(args, 371 "clear-events", "clear the events that occured when executing the command", &clearEvents, 372 "c|clear", "clear screen before executing command",&conf.global.clearScreen, 373 "d|debounce", format!"set the timeout between detected change and command execution (default: %sms)"(debounce), &debounce, 374 "env", "set WATCHEXEC_EVENT environment variables when executing the command", &conf.global.setEnv, 375 "exclude", "ignore modifications to paths matching the pattern (glob: <empty>)", &conf.global.exclude, 376 "e|ext", "file extensions, excluding dot, to watch (default: any)", &monitorExtensions, 377 "include", "ignore all modifications except those matching the pattern (glob: *)", &conf.global.include, 378 "meta", "watch for metadata changes (date, open/close, permission)", &conf.global.watchMetadata, 379 "no-default-ignore", "skip auto-ignoring of commonly ignored globs", &noDefaultIgnore, 380 "no-vcs-ignore", "skip auto-loading of .gitignore files for filtering", &noVcsIgnore, 381 "notify", format!"use %s for desktop notification with commands exit status and this msg"(notifySendCmd), &conf.global.useNotifySend, 382 "o|oneshot", "run in one-shot mode where the command is executed if files are different from watchexec.json", &conf.global.oneShotMode, 383 "oneshot-db", "json database to use", &conf.global.jsonDb, 384 "p|postpone", "wait until first change to execute command", &conf.global.postPone, 385 "r|restart", "restart the process if it's still running", &conf.global.restart, 386 "shell", "(deprecated) run the command in a shell (/bin/sh)", &conf.global.useShell, 387 "s|signal", "send signal to process upon changes, e.g. SIGHUP (default: SIGKILL)", &conf.global.signal, 388 "t|timeout", format!"max runtime of the command (default: %ss)"(timeout), &timeout, 389 "v|verbose", format("Set the verbosity (%-(%s, %))", [EnumMembers!(VerboseMode)]), &conf.global.verbosity, 390 "w|watch", "watch a specific directory", &paths, 391 ); 392 // dfmt on 393 394 conf.global.clearEvents = clearEvents; 395 396 include ~= monitorExtensions.map!(a => format!"*.%s"(a)).array; 397 if (include.empty) { 398 conf.global.include = ["*"]; 399 } else { 400 conf.global.include = include; 401 } 402 403 if (!noVcsIgnore && existsAnd!isFile(Path(".gitignore"))) { 404 import std.file : readText; 405 406 try { 407 conf.global.exclude ~= parseGitIgnore(readText(".gitignore")); 408 } catch (Exception e) { 409 logger.warning(e.msg); 410 } 411 } else if (!noDefaultIgnore && !noVcsIgnore) { 412 conf.global.exclude ~= defaultExclude; 413 } 414 conf.global.useVcsIgnore = !noVcsIgnore; 415 416 if (conf.global.useNotifySend) { 417 if (!whichFromEnv("PATH", notifySendCmd)) { 418 conf.global.useNotifySend = null; 419 logger.warningf("--notify requires the command %s", notifySendCmd); 420 } 421 } 422 423 conf.global.timeout = timeout.dur!"seconds"; 424 conf.global.debounce = debounce.dur!"msecs"; 425 conf.global.paths = paths.map!(a => AbsolutePath(a)).array; 426 427 conf.global.help = conf.global.helpInfo.helpWanted; 428 } catch (std.getopt.GetOptException e) { 429 // unknown option 430 logger.error(e.msg); 431 } catch (Exception e) { 432 logger.error(e.msg); 433 } 434 435 return conf; 436 } 437 438 /** Returns: the glob patterns from `content`. 439 * 440 * The syntax is the one found in .gitignore files. 441 */ 442 string[] parseGitIgnore(string content) { 443 import std.algorithm : splitter; 444 import std.array : appender; 445 import std.ascii : newline; 446 import std.string : strip; 447 448 auto app = appender!(string[])(); 449 450 foreach (l; content.splitter(newline).filter!(a => !a.empty) 451 .filter!(a => a[0] != '#')) { 452 app.put(l.strip); 453 } 454 455 return app.data; 456 } 457 458 @("shall parse a file with gitignore syntax") 459 unittest { 460 auto res = parseGitIgnore(`*.[oa] 461 *.obj 462 *.svn 463 464 # editor junk files 465 *~ 466 *.orig 467 tags 468 *.swp 469 470 # dlang 471 build/ 472 .dub 473 docs.json 474 __dummy.html 475 *.lst 476 __test__*__ 477 478 # rust 479 target/ 480 **/*.rs.bk 481 482 # python 483 *.pyc 484 485 repo.tar.gz`); 486 assert(res == [ 487 "*.[oa]", "*.obj", "*.svn", "*~", "*.orig", "tags", "*.swp", "build/", 488 ".dub", "docs.json", "__dummy.html", "*.lst", "__test__*__", 489 "target/", "**/*.rs.bk", "*.pyc", "repo.tar.gz" 490 ]); 491 } 492 493 GlobFilter[AbsolutePath] parseGitIgnoreRecursive(string[] includes, AbsolutePath[] roots) { 494 import std.array : appender; 495 import std.file : dirEntries, SpanMode, isFile, readText; 496 import std.path : baseName, dirName; 497 498 GlobFilter[AbsolutePath] rval; 499 500 foreach (gf; roots.map!(a => dirEntries(a.toString, SpanMode.depth)) 501 .joiner 502 .filter!isFile 503 .filter!(a => a.baseName == ".gitignore")) { 504 try { 505 rval[AbsolutePath(gf.name.dirName)] = GlobFilter(includes, parseGitIgnore(readText(gf))); 506 } catch (Exception e) { 507 } 508 } 509 510 return rval; 511 }