methadone 1.0.0.rc2 → 1.0.0.rc3

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.
Files changed (40) hide show
  1. data/bin/methadone +2 -2
  2. data/lib/methadone/cucumber.rb +6 -3
  3. data/lib/methadone/error.rb +3 -2
  4. data/lib/methadone/exit_now.rb +25 -0
  5. data/lib/methadone/main.rb +21 -10
  6. data/lib/methadone/sh.rb +19 -4
  7. data/lib/methadone/version.rb +1 -1
  8. data/lib/methadone.rb +1 -0
  9. data/templates/full/Rakefile.erb +1 -1
  10. data/templates/full/features/step_definitions/executable_steps.rb.erb +1 -1
  11. data/test/test_exit_now.rb +37 -0
  12. data/test/test_main.rb +11 -5
  13. data/test/test_sh.rb +17 -0
  14. data/tutorial/.vimrc +6 -0
  15. data/tutorial/1_intro.md +52 -0
  16. data/tutorial/2_bootstrap.md +176 -0
  17. data/tutorial/3_ui.md +349 -0
  18. data/tutorial/4_happy_path.md +437 -0
  19. data/tutorial/5_more_features.md +702 -0
  20. data/tutorial/6_refactor.md +220 -0
  21. data/tutorial/7_logging_debugging.md +304 -0
  22. data/tutorial/8_conclusion.md +11 -0
  23. data/tutorial/code/.rvmrc +1 -0
  24. data/tutorial/code/fullstop/.gitignore +5 -0
  25. data/tutorial/code/fullstop/Gemfile +4 -0
  26. data/tutorial/code/fullstop/LICENSE.txt +202 -0
  27. data/tutorial/code/fullstop/README.rdoc +23 -0
  28. data/tutorial/code/fullstop/Rakefile +31 -0
  29. data/tutorial/code/fullstop/bin/fullstop +43 -0
  30. data/tutorial/code/fullstop/features/fullstop.feature +40 -0
  31. data/tutorial/code/fullstop/features/step_definitions/fullstop_steps.rb +64 -0
  32. data/tutorial/code/fullstop/features/support/env.rb +22 -0
  33. data/tutorial/code/fullstop/fullstop.gemspec +28 -0
  34. data/tutorial/code/fullstop/lib/fullstop/repo.rb +38 -0
  35. data/tutorial/code/fullstop/lib/fullstop/version.rb +3 -0
  36. data/tutorial/code/fullstop/lib/fullstop.rb +2 -0
  37. data/tutorial/code/fullstop/test/tc_something.rb +7 -0
  38. data/tutorial/en.utf-8.add +18 -0
  39. data/tutorial/toc.md +27 -0
  40. metadata +49 -20
@@ -0,0 +1,702 @@
1
+ # Adding Features
2
+
3
+ Our command-line app isn't very interesting at this point; it's more of a glorified shell script. Where Ruby and Methadone
4
+ really shine is when things start getting complex. There's a lot of features we can add and error cases we can handle, for
5
+ example:
6
+
7
+ * The app will blow up if the git repo is already cloned
8
+ * The app might blow up if the files are already symlinked
9
+ * The app won't symlink new files
10
+ * The app checks out your dotfiles repo in your home directory
11
+ * The app uses symlinks, but we might want copies instead
12
+
13
+ You can probably think of even more features. To demonstrate how Methadone works, we're going to add these features:
14
+
15
+ * a "force" switch that will blow away the git repo and re-clone it, called `--force`
16
+ * a "location" flag that will allow us to control where the repo gets cloned, called `--checkout-dir` (which we'll also make available via `-d` as a mnemonic for "directory". See [my book][clibook] for an in-depth discussion on why you should provide long-form options along with short-form options)
17
+
18
+ Since we're working outside-in, we'll first create the user interface by modifying our UI scenario:
19
+
20
+ ```cucumber
21
+ Feature: Checkout dotfiles
22
+ In order to get my dotfiles onto a new computer
23
+ I want a one-command way to keep them up to date
24
+ So I don't have to do it myself
25
+
26
+ Scenario: Basic UI
27
+ When I get help for "fullstop"
28
+ Then the exit status should be 0
29
+ And the banner should be present
30
+ And there should be a one line summary of what the app does
31
+ And the banner should include the version
32
+ And the banner should document that this app takes options
33
+ And the banner should document that this app's arguments are:
34
+ |repo_url|which is required|
35
+ # vvv
36
+ And the following options should be documented:
37
+ | --force |
38
+ | --checkout-dir |
39
+ | -d |
40
+ # ^^^
41
+
42
+ Scenario: Happy Path
43
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
44
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
45
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
46
+ And the files in "~/dotfiles" should be symlinked in my home directory
47
+ ```
48
+
49
+ We've added the step "And the following options should be documented". This will allow us to write the code necessary to handle
50
+ these options. First, we'll run our tests to see that we have a failing scenario:
51
+
52
+ ```sh
53
+ $ rake features
54
+ Feature: Checkout dotfiles
55
+ In order to get my dotfiles onto a new computer
56
+ I want a one-command way to keep them up to date
57
+ So I don't have to do it myself
58
+
59
+ Scenario: Basic UI
60
+ When I get help for "fullstop"
61
+ Then the exit status should be 0
62
+ And the banner should be present
63
+ And there should be a one line summary of what the app does
64
+ And the banner should include the version
65
+ And the banner should document that this app takes options
66
+ And the banner should document that this app's arguments are:
67
+ | repo_url | which is required |
68
+ And the following options should be documented:
69
+ | --force |
70
+ | --checkout-dir |
71
+ | -d |
72
+ expected: /^\s*--force\s+\w\w\w+/m
73
+ got: "Usage: fullstop [options] repo_url\n\nManages dotfiles from a git repo\n\nv0.0.1\n\nOptions:\n --version Show help/version info\n --log-level LEVEL Set the logging level (debug|info|warn|error|fatal)\n (Default: info)\n" (using =~)
74
+ Diff:
75
+ @@ -1,2 +1,11 @@
76
+ -/^\s*--force\s+\w\w\w+/m
77
+ +Usage: fullstop [options] repo_url
78
+ +
79
+ +Manages dotfiles from a git repo
80
+ +
81
+ +v0.0.1
82
+ +
83
+ +Options:
84
+ + --version Show help/version info
85
+ + --log-level LEVEL Set the logging level (debug|info|warn|error|fatal)
86
+ + (Default: info)
87
+ (RSpec::Expectations::ExpectationNotMetError)
88
+ features/fullstop.feature:15:in `And the following options should be documented:'
89
+
90
+ Scenario: Happy Path
91
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
92
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
93
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
94
+ And the files in "~/dotfiles" should be symlinked in my home directory
95
+
96
+ Failing Scenarios:
97
+ cucumber features/fullstop.feature:6
98
+
99
+ 2 scenarios (1 failed, 1 passed)
100
+ 12 steps (1 failed, 11 passed)
101
+ 0m0.574s
102
+ rake aborted!
103
+ Cucumber failed
104
+
105
+ Tasks: TOP => features
106
+ (See full trace by running task with --trace)
107
+ ```
108
+
109
+ As you can see, our tests are failing, because the documentation of the existence of our command-line option couldn't be found in
110
+ the help output.
111
+
112
+ To make this test pass in an `OptionParser`-driven application, you might write something like this:
113
+
114
+ ```ruby
115
+ options = {
116
+ 'checkout-dir' => ENV['HOME'],
117
+ }
118
+ parser = OptionParser.new do |opts|
119
+ opts.on("--force","Force overwriting existing files") do
120
+ options[:force] = true
121
+ end
122
+ opts.on("-d DIR","--checkout-dir",
123
+ "Set the location of the checkout dir",
124
+ "(default: #{options['checkout-dir']})") do |dir|
125
+ options['checkout-dir'] = dir
126
+ end
127
+ end
128
+ parser.parse!
129
+ ```
130
+
131
+ As we learned earlier, Methadone manages an instance of `OptionParser` for us (and calls `parse!` in its `go!` method), so we could reduce this code to:
132
+
133
+ ```ruby
134
+ options = {
135
+ 'checkout-dir' => ENV['HOME'],
136
+ }
137
+ opts.on("--force","Force overwriting existing files") do
138
+ options[:force] = true
139
+ end
140
+ opts.on("-d DIR","--checkout-dir",
141
+ "Set the location of the checkout dir",
142
+ "(default: #{options['checkout-dir']})") do |dir|
143
+ options['checkout-dir'] = dir
144
+ end
145
+ ```
146
+
147
+ Methadone *also* manages an options hash for us, so that it can be made available to the `main` block. It's available via the
148
+ method `options`. Methadone also provides a method `on` that delegates all of its arguments to the underlying `OptionParser`'s
149
+ `on` method. With both of these in mind, we could further reduce the code to:
150
+
151
+ ```ruby
152
+ options['checkout-dir'] = ENV['HOME']
153
+ on("--force","Force overwriting existing files") do
154
+ options[:force] = true
155
+ end
156
+ on("-d DIR","--checkout-dir",
157
+ "Set the location of the checkout dir",
158
+ "(default: #{options['checkout-dir']})") do |dir|
159
+ options['checkout-dir'] = dir
160
+ end
161
+ ```
162
+
163
+ This is still pretty tedious:
164
+
165
+ * Both blocks to `on` just set the value in the `options` hash.
166
+ * We have to include the default value of `checkout-dir` in the documentation string.
167
+
168
+ For apps with a lot of options, this can be a real pain to maintain. Methadone has us covered. The `on` method has more smarts
169
+ than just delegating to `OptionParser`. Specifically:
170
+
171
+ * If you do *not* pass a block to `on`, it will provide `OptionParser` a block that sets the value of the command-line option inside the `options` hash.
172
+ * If there is a default value for your flag (option that takes an argument), it will be included in the help string automatically.
173
+
174
+ In other words, we can reduce our option parsing code to these three lines:
175
+
176
+ ```ruby
177
+ options['checkout-dir'] = ENV['HOME']
178
+
179
+ on("--force","Force overwriting of existing files")
180
+ on("-d DIR","--checkout-dir","Set the location of the checkout dir")
181
+ ```
182
+
183
+ That's it! 14 lines become 3. When `main` executes, the following keys in `options` will be available:
184
+
185
+ * `"force"` - true if the user specified `--force`
186
+ * `:force` - the same
187
+ * `"d"` - the value of the checkout dir (as given to `-d` or `--checkout-dir`), or the default, i.e. never `nil`
188
+ * `:d` - the same
189
+ * `"checkout-dir"` - the same
190
+ * `:'checkout-dir'` - the same
191
+
192
+ Notice that each flag is available as a `String` or `Symbol` and that all forms of each option are present in the hash, meaning
193
+ you can refer to the options in whichever way makes the code most readable.
194
+
195
+ Coming back to our app, let's add this code and see if our test passes. Here's what `bin/fullstop` looks like now:
196
+
197
+ ```ruby
198
+ #!/usr/bin/env ruby
199
+
200
+ require 'optparse'
201
+ require 'methadone'
202
+ require 'fullstop'
203
+ require 'fileutils'
204
+
205
+ class App
206
+ include Methadone::Main
207
+ include Methadone::CLILogging
208
+ include Methadone::SH
209
+
210
+ main do |repo_url|
211
+
212
+ # vvv
213
+ Dir.chdir options['checkout-dir'] do
214
+ # ^^^
215
+ sh "git clone #{repo_url}"
216
+ basedir = repo_url.split(/\//)[-1].gsub(/\.git$/,'')
217
+ Dir.entries(basedir).each do |file|
218
+ next if file == '.' || file == '..' || file == '.git'
219
+ FileUtils.ln_s file,'.'
220
+ end
221
+ end
222
+ end
223
+
224
+ version Fullstop::VERSION
225
+
226
+ description 'Manages dotfiles from a git repo'
227
+
228
+ # vvv
229
+ options['checkout-dir'] = ENV['HOME']
230
+ on("--force","Overwrite files if they exist")
231
+ on("-d DIR","--checkout-dir","Where to clone the repo")
232
+ # ^^^
233
+
234
+ arg :repo_url, "URL to the git repository containing your dotfiles"
235
+
236
+ use_log_level_option
237
+
238
+ go!
239
+ end
240
+ ```
241
+
242
+ We've highlighted the lines we changed. Note that, because we added the option to change the checkout dir, we started using it
243
+ inside `main`. If we wanted to be very strict in our TDD, we would've written a test for that, but that's a bit outside the
244
+ scope of Methadone (feel free to do this on your own, however!).
245
+
246
+ Now, we can see that our test passes, *and* our other scenario doesn't fail, meaning we didn't introduce any bugs.
247
+
248
+ ```sh
249
+ $ rake features
250
+ Feature: Checkout dotfiles
251
+ In order to get my dotfiles onto a new computer
252
+ I want a one-command way to keep them up to date
253
+ So I don't have to do it myself
254
+
255
+ Scenario: Basic UI
256
+ When I get help for "fullstop"
257
+ Then the exit status should be 0
258
+ And the banner should be present
259
+ And there should be a one line summary of what the app does
260
+ And the banner should include the version
261
+ And the banner should document that this app takes options
262
+ And the banner should document that this app's arguments are:
263
+ | repo_url | which is required |
264
+ And the following options should be documented:
265
+ | --force |
266
+ | --checkout-dir |
267
+ | -d |
268
+
269
+ Scenario: Happy Path
270
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
271
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
272
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
273
+ And the files in "~/dotfiles" should be symlinked in my home directory
274
+
275
+ 2 scenarios (2 passed)
276
+ 12 steps (12 passed)
277
+ 0m0.398s
278
+ ```
279
+
280
+ We can also see the UI that's generated by this small amount of code:
281
+
282
+ ```sh
283
+ $ bundle exec bin/fullstop --help
284
+ Usage: fullstop [options] repo_url
285
+
286
+ Manages dotfiles from a git repo
287
+
288
+ v0.0.1
289
+
290
+ Options:
291
+ --version Show help/version info
292
+ --force Overwrite files if they exist
293
+ -d, --checkout-dir DIR Where to clone the repo
294
+ (default: /Users/davec)
295
+ --log-level LEVEL Set the logging level (debug|info|warn|error|fatal)
296
+ (Default: info)
297
+ ```
298
+
299
+ Take a moment to reflect on everything you're getting. We specify only the names of options and their description, and Methadone
300
+ handles the rest. *And*, you can avoid all of this magic entirely, if you really need to, since you have access to the
301
+ `OptionParser` instance via the `opts` method. You get all of the power of `OptionParser`, but without any framework lock-in.
302
+
303
+ For completeness, let's go ahead an implement the two features now that we have the UI in place. To do this, we'll create two
304
+ new scenarios.
305
+
306
+ First, let's implement the `--force` flag with the following scneario:
307
+
308
+ ```cucumber
309
+ Scenario: Force overwrite
310
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
311
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
312
+ And there's a new file in the git repo
313
+ When I run `fullstop --force file:///tmp/dotfiles.git`
314
+ Then the dotfiles in "~/dotfiles" should be re-cloned
315
+ And the files in "~/dotfiles" should be symlinked in my home directory
316
+ ```
317
+
318
+ Several of these steps aren't there, so we'll have to implement them ourselves. Rather than get wrapped up into
319
+ that, let's focus on getting our app to pass this scenario. Supposing these steps are implemented, we now have a failing
320
+ scenario:
321
+
322
+ ```sh
323
+ $ rake features
324
+ Feature: Checkout dotfiles
325
+ In order to get my dotfiles onto a new computer
326
+ I want a one-command way to keep them up to date
327
+ So I don't have to do it myself
328
+
329
+ Scenario: Basic UI
330
+ When I get help for "fullstop"
331
+ Then the exit status should be 0
332
+ And the banner should be present
333
+ And there should be a one line summary of what the app does
334
+ And the banner should include the version
335
+ And the banner should document that this app takes options
336
+ And the banner should document that this app's arguments are:
337
+ | repo_url | which is required |
338
+ And the following options should be documented:
339
+ | --force |
340
+ | --checkout-dir |
341
+ | -d |
342
+
343
+ Scenario: Happy Path
344
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
345
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
346
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
347
+ And the files in "~/dotfiles" should be symlinked in my home directory
348
+
349
+ Scenario: Force overwrite
350
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
351
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
352
+ And there's a new file in the git repo
353
+ When I run `fullstop --force file:///tmp/dotfiles.git`
354
+ Then the dotfiles in "~/dotfiles" should be re-cloned
355
+ expected: true
356
+ got: false (using ==) (RSpec::Expectations::ExpectationNotMetError)
357
+ ./features/step_definitions/fullstop_steps.rb:35:in `block (3 levels) in <top (required)>'
358
+ ./features/step_definitions/fullstop_steps.rb:33:in `each'
359
+ ./features/step_definitions/fullstop_steps.rb:33:in `block (2 levels) in <top (required)>'
360
+ ./features/step_definitions/fullstop_steps.rb:32:in `chdir'
361
+ ./features/step_definitions/fullstop_steps.rb:32:in `/^the dotfiles should be checked out in the directory "([^"]*)"$/'
362
+ features/fullstop.feature:31:in `Then the dotfiles in "~/dotfiles" should be re-cloned'
363
+ And the files in "~/dotfiles" should be symlinked in my home directory
364
+
365
+ Failing Scenarios:
366
+ cucumber features/fullstop.feature:26
367
+
368
+ 3 scenarios (1 failed, 2 passed)
369
+ 18 steps (1 failed, 1 skipped, 16 passed)
370
+ 0m0.703s
371
+ rake aborted!
372
+ Cucumber failed
373
+
374
+ Tasks: TOP => features
375
+ (See full trace by running task with --trace)
376
+ ```
377
+
378
+ We're failing because the new file we added to our repo after the initial clone can't be found. It's likely that our second
379
+ clone failed, but we didn't notice, because we aren't checking. If we run our app manually, we can see that errors are flying,
380
+ but we're ignoring them:
381
+
382
+ ```sh
383
+ $ HOME=/tmp/fake-home bundle exec bin/fullstop file:///tmp/dotfiles.git
384
+ $ HOME=/tmp/fake-home bundle exec bin/fullstop file:///tmp/dotfiles.git
385
+ Error output of 'git clone file:///tmp/dotfiles.git': fatal: destination path 'dotfiles' already exists and is not an empty directory.
386
+ Output of 'git clone file:///tmp/dotfiles.git':
387
+ Error running 'git clone file:///tmp/dotfiles.git'
388
+ File exists - (.bashrc, ./.bashrc)
389
+ $ echo $?
390
+ 70
391
+ ```
392
+
393
+ We can see that error output is being produced from `git`, but we're ignoring it. `fullstop` fails later in the process when we
394
+ ask it to symlink files that already exist. This is actually a bug, so let's take a short detour and fix
395
+ this problem. When doing TDD, it's important to know how your app is failing, so you can be confident that the code you are
396
+ about to write fixes the correct failing in the existing app.
397
+
398
+ We'll write a scenario to reveal the bug:
399
+
400
+ ```cucumber
401
+ Scenario: Fail if directory is cloned
402
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
403
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
404
+ And there's a new file in the git repo
405
+ When I run `fullstop file:///tmp/dotfiles.git`
406
+ Then the exit status should not be 0
407
+ And the stderr should contain "checkout dir already exists, use --force to overwrite"
408
+ ```
409
+
410
+ Now, we can see that it doesn't pass:
411
+ ```sh
412
+ $ rake features
413
+ Feature: Checkout dotfiles
414
+ In order to get my dotfiles onto a new computer
415
+ I want a one-command way to keep them up to date
416
+ So I don't have to do it myself
417
+
418
+ Scenario: Basic UI
419
+ When I get help for "fullstop"
420
+ Then the exit status should be 0
421
+ And the banner should be present
422
+ And there should be a one line summary of what the app does
423
+ And the banner should include the version
424
+ And the banner should document that this app takes options
425
+ And the banner should document that this app's arguments are:
426
+ | repo_url | which is required |
427
+ And the following options should be documented:
428
+ | --force |
429
+ | --checkout-dir |
430
+ | -d |
431
+
432
+ Scenario: Happy Path
433
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
434
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
435
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
436
+ And the files in "~/dotfiles" should be symlinked in my home directory
437
+
438
+ Scenario: Fail if directory is cloned
439
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
440
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
441
+ And there's a new file in the git repo
442
+ When I run `fullstop file:///tmp/dotfiles.git`
443
+ Then the exit status should not be 0
444
+ And the stderr should contain "checkout dir already exists, uses --force to overwrite"
445
+ expected "W, [2012-02-12T15:12:35.720851 #43530] WARN -- : Error output of 'git clone file:///tmp/dotfiles.git': fatal: destination path 'dotfiles' already exists and is not an empty directory.\nW, [2012-02-12T15:12:35.720964 #43530] WARN -- : Error running 'git clone file:///tmp/dotfiles.git'\nE, [2012-02-12T15:12:35.721503 #43530] ERROR -- : File exists - (.bashrc, ./.bashrc)\n" to include "checkout dir already exists, uses --force to overwrite"
446
+ Diff:
447
+ @@ -1,2 +1,4 @@
448
+ -["checkout dir already exists, uses --force to overwrite"]
449
+ +W, [2012-02-12T15:12:35.720851 #43530] WARN -- : Error output of 'git clone file:///tmp/dotfiles.git': fatal: destination path 'dotfiles' already exists and is not an empty directory.
450
+ +W, [2012-02-12T15:12:35.720964 #43530] WARN -- : Error running 'git clone file:///tmp/dotfiles.git'
451
+ +E, [2012-02-12T15:12:35.721503 #43530] ERROR -- : File exists - (.bashrc, ./.bashrc)
452
+ (RSpec::Expectations::ExpectationNotMetError)
453
+ features/fullstop.feature:32:in `And the stderr should contain "checkout dir already exists, uses --force to overwrite"'
454
+
455
+ Failing Scenarios:
456
+ cucumber features/fullstop.feature:26
457
+
458
+ 3 scenarios (1 failed, 2 passed)
459
+ 18 steps (1 failed, 17 passed)
460
+ 0m0.694s
461
+ rake aborted!
462
+ Cucumber failed
463
+
464
+ Tasks: TOP => features
465
+ (See full trace by running task with --trace)
466
+ ```
467
+
468
+ It looks like `fullstop` is writing log messages. It is, and we'll talk about that more later, but right now, we need to focus
469
+ on the fact that we aren't producing the error message we expect. Let's modify `bin/fullstop` to check that the call to `git`
470
+ succeeded. `sh` returns the exit status of the command it calls, so we can use that to fix things.
471
+
472
+ Here's the changes we'll make to `bin/fullstop` to check for this:
473
+
474
+ ```ruby
475
+ #!/usr/bin/env ruby
476
+
477
+ require 'optparse'
478
+ require 'methadone'
479
+ require 'fullstop'
480
+ require 'fileutils'
481
+
482
+ class App
483
+ include Methadone::Main
484
+ include Methadone::CLILogging
485
+ include Methadone::SH
486
+
487
+ main do |repo_url|
488
+
489
+ Dir.chdir options['checkout-dir'] do
490
+ # vvv
491
+ if sh("git clone #{repo_url}") == 0
492
+ # ^^^
493
+ basedir = repo_url.split(/\//)[-1].gsub(/\.git$/,'')
494
+ Dir.entries(basedir).each do |file|
495
+ next if file == '.' || file == '..' || file == '.git'
496
+ FileUtils.ln_s file,'.'
497
+ end
498
+ else
499
+ # vvv
500
+ exit_now!("checkout dir already exists, use --force to overwrite")
501
+ # ^^^
502
+ end
503
+ end
504
+ end
505
+
506
+ version Fullstop::VERSION
507
+
508
+ description 'Manages dotfiles from a git repo'
509
+
510
+ options['checkout-dir'] = ENV['HOME']
511
+ on("--force","Overwrite files if they exist")
512
+ on("-d DIR","--checkout-dir","Where to clone the repo")
513
+
514
+ arg :repo_url, "URL to the git repository containing your dotfiles"
515
+
516
+ use_log_level_option
517
+
518
+ go!
519
+ end
520
+ ```
521
+
522
+ Since an exit of zero is considered success, we branch on that value, and call the method `exit_now!`, provided by Methadone, to
523
+ stop the app with an error. The argument is an error message to
524
+ print to the standard error to let the user know why the app stopped abnormally. The app will then stop and exit nonzero. If
525
+ you want to customize the exit code, you can provide it as the first argument, with the message being the second argument.
526
+
527
+ As you can see, our test now passes:
528
+
529
+ ```sh
530
+ $ rake features
531
+ Feature: Checkout dotfiles
532
+ In order to get my dotfiles onto a new computer
533
+ I want a one-command way to keep them up to date
534
+ So I don't have to do it myself
535
+
536
+ Scenario: Basic UI
537
+ When I get help for "fullstop"
538
+ Then the exit status should be 0
539
+ And the banner should be present
540
+ And there should be a one line summary of what the app does
541
+ And the banner should include the version
542
+ And the banner should document that this app takes options
543
+ And the banner should document that this app's arguments are:
544
+ | repo_url | which is required |
545
+ And the following options should be documented:
546
+ | --force |
547
+ | --checkout-dir |
548
+ | -d |
549
+
550
+ Scenario: Happy Path
551
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
552
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
553
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
554
+ And the files in "~/dotfiles" should be symlinked in my home directory
555
+
556
+ Scenario: Fail if directory is cloned
557
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
558
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
559
+ And there's a new file in the git repo
560
+ When I run `fullstop file:///tmp/dotfiles.git`
561
+ Then the exit status should not be 0
562
+ And the stderr should contain "checkout dir already exists, use --force to overwrite"
563
+
564
+ 3 scenarios (3 passed)
565
+ 18 steps (18 passed)
566
+ 0m0.789s
567
+ ```
568
+
569
+ Note that there is a companion method to `sh`, called `sh!` that will throw an exception if the underlying command it calls
570
+ fails. In a Methadone app, any unhandled exception will trigger a nonzero exit from the app, and show the user the message of
571
+ the exception that caused the exit. We can customize the message of the exception thrown from `sh!`, and thus our change
572
+ to our app could also be implemented like so:
573
+
574
+ ```ruby
575
+ main do |repo_url|
576
+
577
+ Dir.chdir options['checkout-dir'] do
578
+ # vvv
579
+ sh! git clone #{repo_url}", :on_fail => "checkout dir already exists, use --force to overwrite"
580
+ # ^^^
581
+ basedir = repo_url.split(/\//)[-1].gsub(/\.git$/,'')
582
+ Dir.entries(basedir).each do |file|
583
+ next if file == '.' || file == '..' || file == '.git'
584
+ FileUtils.ln_s file,'.'
585
+ end
586
+ end
587
+ end
588
+ ```
589
+
590
+ Which method to use is purely stylistic and up to you.
591
+
592
+ NOW, we can get back to the `--force` flag. We're going to change our scenario a bit, as well. Instead of using "When I run `fullstop --force file:///tmp/dotfiles.git`" we'll use "When I successfully run `fullstop --force file:///tmp/dotfiles.git`", which will fail if the app exits nonzero. This will cause our scenario to fail earlier.
593
+
594
+ To fix this, we'll change the code in `bin/fullstop` so that if the user specified `--force`, we'll delete the directory before we
595
+ clone. We'll also need to delete the files that were symlinked in the home directory as well.
596
+
597
+ ```ruby
598
+ #!/usr/bin/env ruby
599
+
600
+ require 'optparse'
601
+ require 'methadone'
602
+ require 'fullstop'
603
+ require 'fileutils'
604
+
605
+ class App
606
+ include Methadone::Main
607
+ include Methadone::CLILogging
608
+ include Methadone::SH
609
+
610
+ main do |repo_url|
611
+
612
+ Dir.chdir options['checkout-dir'] do
613
+ basedir = repo_url.split(/\//)[-1].gsub(/\.git$/,'')
614
+ # vvv
615
+ if options[:force] && Dir.exists?(basedir)
616
+ warn "deleting #{basedir} before cloning"
617
+ FileUtils.rm_rf basedir
618
+ end
619
+ # ^^^
620
+ if sh("git clone #{repo_url}") == 0
621
+ Dir.entries(basedir).each do |file|
622
+ next if file == '.' || file == '..' || file == '.git'
623
+ source_file = File.join(basedir,file)
624
+ # vvv
625
+ FileUtils.rm(file) if File.exists?(file) && options[:force]
626
+ # ^^^
627
+ FileUtils.ln_s source_file,'.'
628
+ end
629
+ else
630
+ exit_now!("checkout dir already exists, use --force to overwrite")
631
+ end
632
+ end
633
+ end
634
+
635
+ version Fullstop::VERSION
636
+
637
+ description 'Manages dotfiles from a git repo'
638
+
639
+ options['checkout-dir'] = ENV['HOME']
640
+ on("--force","Overwrite files if they exist")
641
+ on("-d DIR","--checkout-dir","Where to clone the repo")
642
+
643
+ arg :repo_url, "URL to the git repository containing your dotfiles"
644
+
645
+ use_log_level_option
646
+
647
+ go!
648
+ end
649
+ ```
650
+
651
+ Now, we can see that our scenario passes:
652
+
653
+ ```sh
654
+ $ rake features
655
+ Feature: Checkout dotfiles
656
+ In order to get my dotfiles onto a new computer
657
+ I want a one-command way to keep them up to date
658
+ So I don't have to do it myself
659
+
660
+ Scenario: Basic UI
661
+ When I get help for "fullstop"
662
+ Then the exit status should be 0
663
+ And the banner should be present
664
+ And there should be a one line summary of what the app does
665
+ And the banner should include the version
666
+ And the banner should document that this app takes options
667
+ And the banner should document that this app's arguments are:
668
+ | repo_url | which is required |
669
+ And the following options should be documented:
670
+ | --force |
671
+ | --checkout-dir |
672
+ | -d |
673
+
674
+ Scenario: Happy Path
675
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
676
+ When I successfully run `fullstop file:///tmp/dotfiles.git`
677
+ Then the dotfiles should be checked out in the directory "~/dotfiles"
678
+ And the files in "~/dotfiles" should be symlinked in my home directory
679
+
680
+ Scenario: Fail if directory is cloned
681
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
682
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
683
+ And there's a new file in the git repo
684
+ When I run `fullstop file:///tmp/dotfiles.git`
685
+ Then the exit status should not be 0
686
+ And the stderr should contain "checkout dir already exists, use --force to overwrite"
687
+
688
+ Scenario: Force overwrite
689
+ Given a git repo with some dotfiles at "/tmp/dotfiles.git"
690
+ And I have my dotfiles cloned and symlinked to "~/dotfiles"
691
+ And there's a new file in the git repo
692
+ When I successfully run `fullstop --force file:///tmp/dotfiles.git`
693
+ Then the dotfiles in "~/dotfiles" should be re-cloned
694
+ And the files in "~/dotfiles" should be symlinked in my home directory
695
+
696
+ 4 scenarios (4 passed)
697
+ 24 steps (24 passed)
698
+ 0m1.171s
699
+ ```
700
+
701
+ We're starting to see a few features of Methadone that need some explanation, such as what those log messages are, and how the
702
+ output of our commands is getting there. But first, our `main` method is also becoming pretty messy. Since Methadone allows and encourages you to write your app cleanly, we can refactor that code into classes that live inside `lib`. We'll see how to do that in the next section.