methadone 1.0.0.rc2 → 1.0.0.rc3

Sign up to get free protection for your applications and to get access to all the features.
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.