├── .gitignore ├── .rbenv-version ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── TODO ├── bin └── briefcase ├── briefcase.gemspec ├── lib ├── briefcase.rb └── briefcase │ ├── commands.rb │ ├── commands │ ├── base.rb │ ├── core │ │ ├── files.rb │ │ ├── output.rb │ │ └── secrets.rb │ ├── generate.rb │ ├── git.rb │ ├── import.rb │ ├── redact.rb │ └── sync.rb │ ├── main.rb │ └── version.rb └── spec ├── bin └── editor ├── generate_spec.rb ├── git_spec.rb ├── helpers ├── assertions.rb ├── commands.rb ├── files.rb └── stubbing.rb ├── import_spec.rb ├── spec_helper.rb └── sync_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | *.gem 21 | 22 | ## PROJECT::SPECIFIC 23 | Gemfile.lock 24 | -------------------------------------------------------------------------------- /.rbenv-version: -------------------------------------------------------------------------------- 1 | 1.8.7-p352 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.2 3 | - 1.8.7 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Jim Benton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Briefcase 2 | 3 | [Briefcase](http://jim.github.com/briefcase/) is a tool to facilitate keeping dotfiles in git, including those with private information (such as .gitconfig). 4 | 5 | By keeping your configuration files in a git public git repository, you can share your settings with others. Any secret information is kept in a single file outside the repository (it’s up to you to backup and transport this file). 6 | 7 | [The project homepage](http://jim.github.com/briefcase/) includes 8 | installation and usage documentation. 9 | 10 | [![Build Status](https://secure.travis-ci.org/jim/briefcase.png)](http://travis-ci.org/jim/briefcase) 11 | 12 | ## Changelog 13 | 14 | * 0.4.2 Git command argument are properly escaped (Sebastian Spieszko) 15 | 16 | Changed the instruction delimiter when redacting files to '!!'. 17 | 18 | * 0.4.1 Git command now properly passes options through to git, and also allows 19 | git output to display in color. 20 | 21 | Path environment variables inherit settings for parent directories. 22 | * 0.4.0 Renamed project to Briefcase. First public release. 23 | * 0.3.0 Added code documentation, internal renaming, general cleanup. First public release. 24 | * 0.2.0 Added redact command, use .redacted for dynamic dotfiles 25 | * 0.1.3 The sync command no longer creates symlinks for dynamic files 26 | * 0.1.2 Added dynamic file generation 27 | 28 | ## Note on Patches/Pull Requests 29 | * Fork the project. 30 | * Make your feature addition or bug fix on a topic branch. 31 | * Add tests for it. This is important so I don't break it in a future version unintentionally. 32 | * Commit, do not mess with rakefile, version, or history. 33 | (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 34 | * Send me a pull request. 35 | 36 | ## Copyright 37 | 38 | Copyright (c) 2012 Jim Benton. See LICENSE for details. 39 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new('spec') do |t| 5 | t.libs << 'spec' 6 | t.pattern = "spec/*_spec.rb" 7 | end 8 | 9 | task :default => :spec 10 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - should not import a symbolic link 2 | - should create a git repo even if dotfiles dir already exists -------------------------------------------------------------------------------- /bin/briefcase: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib') 3 | require 'briefcase/main' -------------------------------------------------------------------------------- /briefcase.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path('lib/briefcase/version', File.dirname(__FILE__)) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{briefcase} 5 | s.version = Briefcase::VERSION 6 | s.summary = %q{Briefcase manages dotfiles and handles keeping their secrets safe} 7 | s.description = %q{Command line program to migrate dotfiles to a git repo at ~/.dotfiles and generate static dotfiles with secret values.} 8 | s.authors = ["Jim Benton"] 9 | s.default_executable = %q{briefcase} 10 | s.email = %q{jim@autonomousmachine.com} 11 | s.executables = ["briefcase"] 12 | s.files = Dir['lib/**/*.rb'] + # library 13 | Dir['bin/*'] + # executable 14 | Dir['spec/**/*.rb'] + # spec files 15 | Dir['spec/bin/editor'] + # spec editor 16 | %w{README.md LICENSE briefcase.gemspec Rakefile} # misc 17 | 18 | s.homepage = %q{http://github.com/jim/briefcase} 19 | s.require_paths = ["lib"] 20 | s.rubygems_version = %q{1.3.7} 21 | s.test_files = Dir['spec/*.rb'] 22 | 23 | s.add_runtime_dependency('commander') 24 | s.add_runtime_dependency('escape') 25 | s.add_development_dependency('minitest') 26 | s.add_development_dependency('open4') 27 | s.add_development_dependency('rake', '0.9.2.2') 28 | s.add_development_dependency('turn') 29 | end 30 | 31 | -------------------------------------------------------------------------------- /lib/briefcase.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | require File.expand_path('briefcase/commands', File.dirname(__FILE__)) 4 | require File.expand_path('briefcase/version', File.dirname(__FILE__)) 5 | 6 | module Briefcase 7 | 8 | class << self 9 | attr_accessor :dotfiles_path, :home_path, :secrets_path, :testing 10 | 11 | # The user's home path 12 | def default_home_path 13 | '~' 14 | end 15 | 16 | # The default path where dotfiles are stored 17 | def default_dotfiles_path 18 | File.join(home_path, '.dotfiles') 19 | end 20 | 21 | # The default path to where secret information is stored 22 | def default_secrets_path 23 | File.join(home_path, '.briefcase_secrets') 24 | end 25 | 26 | def dotfiles_path 27 | @dotfiles_path ||= File.expand_path(ENV['BRIEFCASE_DOTFILES_PATH'] || default_dotfiles_path) 28 | end 29 | 30 | def home_path 31 | @home_path ||= File.expand_path(ENV['BRIEFCASE_HOME_PATH'] || default_home_path) 32 | end 33 | 34 | def secrets_path 35 | @secrets_path ||= File.expand_path(ENV['BRIEFCASE_SECRETS_PATH'] || default_secrets_path) 36 | end 37 | 38 | def testing? 39 | @testing ||= ENV['BRIEFCASE_TESTING'] == 'true' 40 | end 41 | end 42 | 43 | class UnrecoverableError < StandardError; end 44 | class CommandAborted < StandardError; end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/briefcase/commands.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('commands/base', File.dirname(__FILE__)) 2 | require File.expand_path('commands/import', File.dirname(__FILE__)) 3 | require File.expand_path('commands/redact', File.dirname(__FILE__)) 4 | require File.expand_path('commands/sync', File.dirname(__FILE__)) 5 | require File.expand_path('commands/generate', File.dirname(__FILE__)) 6 | require File.expand_path('commands/git', File.dirname(__FILE__)) 7 | -------------------------------------------------------------------------------- /lib/briefcase/commands/base.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require File.expand_path('core/secrets', File.dirname(__FILE__)) 3 | require File.expand_path('core/files', File.dirname(__FILE__)) 4 | require File.expand_path('core/output', File.dirname(__FILE__)) 5 | 6 | module Briefcase 7 | module Commands 8 | 9 | # Briefcase::Commands::Base is the base class for all commands in the system. 10 | # 11 | # Most behavior is actually defined by Core modules. 12 | # 13 | # Actual commands, which are created by creating a subclass of Base, must 14 | # implement an instance method `execute`. 15 | # 16 | # Running a Commands::Base subclass is done by instantiating it with 17 | # arguments and options. 18 | class Base 19 | 20 | # The extension to append to files when redacting information 21 | REDACTED_EXTENSION = 'redacted' 22 | 23 | include FileUtils 24 | include Core::Files 25 | include Core::Secrets 26 | include Core::Output 27 | 28 | def initialize(args, options) 29 | @args = args 30 | @options = options 31 | run 32 | end 33 | 34 | # Begin execution of this command. Subclasses should not override this 35 | # method, instead, they should define an `execute` method that performs 36 | # their actual work. 37 | def run 38 | begin 39 | execute 40 | say('') 41 | success "Done." 42 | rescue CommandAborted, UnrecoverableError => e 43 | error(e.message) 44 | exit(255) 45 | end 46 | end 47 | 48 | # Perform this command's work. 49 | # 50 | # This method should be overridden in subclasses. 51 | def execute 52 | raise "Not Implemented" 53 | end 54 | 55 | # Add a file to the .gitignore file inside the dotfiles_path 56 | # 57 | # filename - The String filename to be appended to the list of ignored paths 58 | # 59 | # Returns the Integer number of bytes written. 60 | def add_to_git_ignore(filename) 61 | File.open(File.join(dotfiles_path, '.gitignore'), "a+") do |file| 62 | contents = file.read 63 | unless contents =~ %r{^#{filename}$} 64 | info("Adding #{filename} to #{File.join(dotfiles_path, '.gitignore')}") 65 | file.write(filename + "\n") 66 | end 67 | end 68 | end 69 | 70 | # Check to see if the dotfiles directory exists. If it doesn't, present 71 | # the user with the option to create it. If the user accepts, the 72 | # directory is created. 73 | # 74 | # If the user declines creating the directory, a CommandAborted exception 75 | # is raised. 76 | def verify_dotfiles_directory_exists 77 | if !File.directory?(dotfiles_path) 78 | choice = choose("You don't appear to have a git repository at #{dotfiles_path}. Do you want to create one now?", 'create', 'abort') do |menu| 79 | menu.index = :letter 80 | menu.layout = :one_line 81 | end 82 | if choice == 'create' 83 | info "Creating a directory at #{dotfiles_path}" 84 | mkdir_p(dotfiles_path) 85 | info `git init #{dotfiles_path}` 86 | else 87 | raise CommandAborted.new('Can not continue without a dotfiles repository!') 88 | end 89 | end 90 | end 91 | 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/briefcase/commands/core/files.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | module Core 4 | module Files 5 | 6 | # Create a symlink at a path with a given target. 7 | # 8 | # target - the String target for the symlink 9 | # destination - The String location for the symlink 10 | def symlink(target, destination) 11 | ln_s(target, destination) 12 | info "Symlinking %s -> %s", destination, target 13 | end 14 | 15 | # Move a file from one location to another 16 | # 17 | # path - The String path to be moved 18 | # destination - The String path to move the file to 19 | def move(path, destination) 20 | mv(path, destination) 21 | info "Moving %s to %s", path, destination 22 | end 23 | 24 | # Write some content to a file path. 25 | # 26 | # path - The String path to the file that should be written to 27 | # content - The String content to write to the file 28 | # io_mode - The String IO mode to use when writing the file 29 | def write_file(path, content, io_mode='w') 30 | File.open(path, io_mode) do |file| 31 | file.write(content) 32 | end 33 | end 34 | 35 | # Returns the globally configured home path for the user. 36 | def home_path 37 | Briefcase.home_path 38 | end 39 | 40 | # Returns the globally configured dotfiles path. 41 | def dotfiles_path 42 | Briefcase.dotfiles_path 43 | end 44 | 45 | # Returns the globally configured secrets path. 46 | def secrets_path 47 | Briefcase.secrets_path 48 | end 49 | 50 | # Build a full dotfile path from a given file path, using the gobal 51 | # dotfiles path setting. 52 | # 53 | # file_path - The String path to build a dotfile path from 54 | def generate_dotfile_path(file_path) 55 | File.join(dotfiles_path, visible_name(file_path)) 56 | end 57 | 58 | # Check to see if there is a stored dotfile in the dotfiles directory 59 | # that corresponds to the specified file. 60 | # 61 | # file_path - The String file name to check 62 | # 63 | # Returns whether the file exists or not as a Boolean 64 | def dotfile_exists?(file_path) 65 | File.exist?(generate_dotfile_path(file_path)) 66 | end 67 | 68 | # Convert a file path into a file name and remove a leading period if 69 | # it exists. 70 | # 71 | # file_path - The String file path to create a visible name for 72 | # 73 | # Returns the manipulated file name 74 | def visible_name(file_path) 75 | File.basename(file_path).gsub(/^\./, '') 76 | end 77 | 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/briefcase/commands/core/output.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | module Core 4 | module Output 5 | 6 | # Print some bold green text to the console. 7 | def success(*args); say $terminal.color(format(*args), :green, :bold); end 8 | 9 | # Print some yellow text to the console. 10 | def info(*args); say $terminal.color(format(*args), :yellow); end 11 | 12 | # Print some red text to the console. 13 | def error(*args); say $terminal.color(format(*args), :red); end 14 | 15 | # Print some magenta text to the console. 16 | def warn(*args); say $terminal.color(format(*args), :magenta); end 17 | 18 | # Print some bold text to the console. 19 | def intro(*args); say $terminal.color(format(*args), :bold); say(''); end 20 | 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/briefcase/commands/core/secrets.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | module Core 4 | module Secrets 5 | 6 | COMMENT_REPLACEMENT_REGEX = /^([^#]*)#\s*briefcase\(([a-zA-Z_]+)\)\s*$/ 7 | 8 | # Add a key and value to the secrets file for the given file. 9 | # 10 | # path - The String path to the file containing the secret. 11 | # key - The String key to store the value as 12 | # value - The String value to store 13 | def add_secret(path, key, value) 14 | path_key = File.basename(path) 15 | secrets[path_key] ||= {} 16 | secrets[path_key][key] = value 17 | end 18 | 19 | # Get a secret value for the given file and key. 20 | # 21 | # path - The String path to the file that contains the secret 22 | # key - The String key to retrieve the value for 23 | # 24 | # Returns the string value from the secrets file for the given key. 25 | def get_secret(path, key) 26 | path_key = File.basename(path) 27 | secrets[path_key][key] if secrets[path_key] && secrets[path_key][key] 28 | end 29 | 30 | # Write the internal secrets hash to the secrets file as YAML. 31 | def write_secrets 32 | write_file(secrets_path, secrets.to_yaml) 33 | end 34 | 35 | # The secrets hash. 36 | # 37 | # Returns the secrets hash if a a secrets file exists, or an empty hash 38 | # if it does not. 39 | def secrets 40 | @secrets ||= if File.exist?(secrets_path) 41 | info "Loading existing secrets from #{secrets_path}" 42 | YAML.load_file(secrets_path) 43 | else 44 | {} 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/briefcase/commands/generate.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | 4 | # Generate looks through through the dotfiles directory for any redacted 5 | # dotfiles. It attempts to generate a normal version of each file it finds, 6 | # using the values stored in the .briefcase_secrets file. 7 | class Generate < Base 8 | 9 | def execute 10 | intro "Generating redacted dotfiles in #{dotfiles_path}" 11 | 12 | Dir.glob(File.join(dotfiles_path, "*.#{REDACTED_EXTENSION}")) do |path| 13 | generate_file_for_path(path) 14 | end 15 | 16 | write_secrets 17 | end 18 | 19 | private 20 | 21 | # Generates a standard dotfile from a redacted dotfile 22 | # 23 | # This method will attempt to find secret values in the secrets file. If 24 | # any aren't found, it will print a message about the offending key and 25 | # add that key to the secrets file without a value (to make it easier for 26 | # users to fill in the missing value). 27 | # 28 | # path - the path to the redacted dotfile 29 | def generate_file_for_path(path) 30 | static_path = path.gsub(/.#{REDACTED_EXTENSION}$/, '') 31 | basename = File.basename(static_path) 32 | dotfile_path = generate_dotfile_path(basename) 33 | 34 | if !File.exist?(dotfile_path) || overwrite_file?(dotfile_path) 35 | info "Generating %s", dotfile_path 36 | content = File.read(path) 37 | edited_content = content.gsub(COMMENT_REPLACEMENT_REGEX) do |match| 38 | key = $2 39 | if (replacement = get_secret(static_path, key)) 40 | info "Restoring secret value for key: #{key}" 41 | $1 + replacement 42 | else 43 | info "Secret missing for key: #{key}" 44 | add_secret(static_path, key, '') 45 | match 46 | end 47 | end 48 | write_file(dotfile_path, edited_content) 49 | else 50 | info "Skipping %s as there is already a file at %s", path, dotfile_path 51 | end 52 | end 53 | 54 | # TODO consolidate this method and Import#overwrite_file? 55 | def overwrite_file?(path) 56 | decision = choose("#{path} already exists as a dotfile. Do you want to replace it?", 'replace', 'skip') do |menu| 57 | menu.index = :letter 58 | menu.layout = :one_line 59 | end 60 | decision == 'replace' 61 | end 62 | 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/briefcase/commands/git.rb: -------------------------------------------------------------------------------- 1 | require 'escape' 2 | 3 | module Briefcase 4 | module Commands 5 | 6 | # Run git commands in the dotfiles directory. 7 | # 8 | # This command simply passes everything passed to it on to the git 9 | # executable on the user's PATH. 10 | # 11 | # A basic equivalent of this command: 12 | # 13 | # briefcase git status 14 | # 15 | # Would be: 16 | # 17 | # cd ~/.dotfiles && git status 18 | class Git < Base 19 | 20 | # Execute a git command in the dotfiles directory. Will prompt the user 21 | # to create a dotfiles directory if it does not exist, which will also 22 | # create an empty git repository. 23 | def execute 24 | verify_dotfiles_directory_exists 25 | command = Escape.shell_command(@args) 26 | intro("Running git %s in %s", command, dotfiles_path) 27 | run_git_command(command) 28 | end 29 | 30 | private 31 | 32 | def run_git_command(command) 33 | $stdout.flush 34 | exec "cd #{dotfiles_path} && git #{command}" 35 | end 36 | 37 | end 38 | end 39 | end 40 | 41 | -------------------------------------------------------------------------------- /lib/briefcase/commands/import.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | 3 | module Briefcase 4 | module Commands 5 | 6 | # Import copies a dotfile into the dotfiles directory and created a 7 | # symlink in the file's previous location, pointing to its new location. 8 | # 9 | # The file's name in the repository will be the dotfile's name with the 10 | # leading period removed. So, importing '~/.bashrc' will move that file to 11 | # ~/.dotfiles/bashrc and then create a symlink at ~/.bashrc pointing to 12 | # ~/.dotfiles/bashrc. 13 | # 14 | # If there was already a file located at ~/.dotfiles/bashrc, the user will 15 | # be asked if it is OK to mvoe the existing file aside. If the user accepts 16 | # the move, the existing file is renamed to bashrc.old.1 before the new 17 | # file is imported, and a message would be shown indicating this has 18 | # occurred. 19 | class Import < Base 20 | 21 | # Execute verifies that the dotfiles directory exists before attempting 22 | # the import. 23 | # 24 | # Raises an error if the specified path does not exist. 25 | def execute 26 | verify_dotfiles_directory_exists 27 | 28 | @path = File.expand_path(@args.first) 29 | raise UnrecoverableError.new("#{@path} does not exist") unless File.exist?(@path) 30 | 31 | intro("Importing %s into %s", @path, dotfiles_path) 32 | import_file 33 | end 34 | 35 | private 36 | 37 | # Import a file. Creates the dotfiles directory if it doesn't exist 38 | # before attempting the import. Prompts the user when there is a naming 39 | # collision between an existing dotfile and the file to be imported. 40 | # 41 | # Raises CommandAborted if there is a colision and the user declines to 42 | # move the existing file. 43 | def import_file 44 | collision = dotfile_exists?(@path) 45 | if !collision || overwrite_file? 46 | 47 | mkdir_p(dotfiles_path) 48 | destination = generate_dotfile_path(@path) 49 | 50 | if collision 51 | existing = Dir.glob("#{destination}.old.*").size 52 | sideline = "#{destination}.old.#{existing+1}" 53 | info "Moving %s to %s", destination, sideline 54 | mv(destination, sideline) 55 | end 56 | 57 | move(@path, destination) 58 | symlink(destination, @path) 59 | else 60 | raise CommandAborted.new('Cancelled.') 61 | end 62 | end 63 | 64 | # Ask the user if it is OK to move an existing file so a new file can be 65 | # imported. 66 | # 67 | # Returns whether the user accepts the move or not as a Boolean. 68 | # 69 | # TODO: Rename this method as it doesn't overwrite anything. 70 | def overwrite_file? 71 | decision = choose("#{@path} already exists as a dotfile. Do you want to replace it? Your original file will be renamed.", 'replace', 'abort') do |menu| 72 | menu.index = :letter 73 | menu.layout = :one_line 74 | end 75 | 76 | decision == 'replace' 77 | end 78 | 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/briefcase/commands/redact.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | 4 | # Redact is similar to Import, but it will also prompt the user to replace 5 | # any secure information in the file with a special replacement syntax. Any 6 | # values replaces in this way will be stored in the secrets file, and the 7 | # dotfile will be imported sans secrets. 8 | class Redact < Import 9 | 10 | EDITING_HELP_TEXT = <<-TEXT 11 | !! Edit the file below, replacing any sensitive information to turn this: 12 | !! 13 | !! password: superSecretPassword 14 | !! 15 | !! Into: 16 | !! 17 | !! password: # briefcase(password) 18 | !! 19 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 20 | TEXT 21 | 22 | private 23 | 24 | def import_file 25 | super 26 | create_redacted_version 27 | end 28 | 29 | # Copy the file to be imported into the dotfiles directory and append the 30 | # redacted extension to its name. This file is then opened in an 31 | # editor, where the user has a chance to replace sensitive information. 32 | # After saving and closing the file, the differences are examined and the 33 | # replaces values are detected. These values and their replacement keys 34 | # are stored in the secrets file. 35 | def create_redacted_version 36 | destination = generate_dotfile_path(@path) 37 | redacted_path = destination + ".#{REDACTED_EXTENSION}" 38 | info "Creating redacted version at #{redacted_path}" 39 | 40 | original_content = File.read(destination) 41 | 42 | content_to_edit = EDITING_HELP_TEXT + original_content 43 | 44 | write_file(redacted_path, content_to_edit) 45 | edited_content = edit_file_with_editor(redacted_path).gsub(/^!!.*\r?\n/, '') 46 | write_file(redacted_path, edited_content) 47 | 48 | edited_content.lines.each_with_index do |line, line_index| 49 | if line =~ COMMENT_REPLACEMENT_REGEX 50 | key = $2 51 | mask = %r{^#{$1}(.*)$} 52 | value = original_content.lines.to_a[line_index].match(mask)[1] 53 | info "Storing secret value for key: #{key}" 54 | add_secret(destination, key, value) 55 | end 56 | end 57 | 58 | write_secrets 59 | add_to_git_ignore(visible_name(destination)) 60 | end 61 | 62 | # Open a file with an editor. The editor can be specified using the 63 | # EDITOR environment variable, with vim being the current default. 64 | # 65 | # Returns the content of the file after the editor is closed. 66 | def edit_file_with_editor(path) 67 | editor_command = ENV['BRIEFCASE_EDITOR'] || 'vim' 68 | system(editor_command, path) 69 | File.read(path) 70 | end 71 | 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/briefcase/commands/sync.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | module Commands 3 | 4 | # Sync scans the dotfiles directory for dotfiles, and creates any missing 5 | # symlinks in the user's home directory. 6 | # 7 | # A message is printed out for each file, indicating if a matching symlink 8 | # was found in the home directory or if one was created. 9 | # 10 | # If there is a symlink in the user's home directory with a dotfile's name 11 | # but it is pointing to the wrong location, it is removed and a new symlink 12 | # is created in its place. 13 | class Sync < Base 14 | 15 | # Scan the dotfiles directory for files, and process each one. Files 16 | # with names containing '.old' are ignored and a warning is show to alert 17 | # the user of their presence. 18 | def execute 19 | intro "Synchronizing dotfiles between #{dotfiles_path} and #{home_path}" 20 | 21 | Dir.glob(File.join(dotfiles_path, '*')) do |path| 22 | basename = File.basename(path) 23 | next if %w{. ..}.include?(basename) 24 | next if basename =~ /.#{REDACTED_EXTENSION}$/ 25 | 26 | if basename.include?('.old') 27 | warn "Skipping %s, you may want to remove it.", path 28 | else 29 | create_or_verify_symlink(basename) 30 | end 31 | end 32 | end 33 | 34 | private 35 | 36 | # Verifies if a symlink following briefcase's conventions exists for the 37 | # supplied filename. Creates a symlink if it doesn't exist, or if there 38 | # is a corectly named symlink with the wrong target. 39 | # 40 | # basename - The String filename to use when building paths 41 | def create_or_verify_symlink(basename) 42 | dotfile_name = ".#{basename}" 43 | symlink_path = File.join(home_path, dotfile_name) 44 | dotfile_path = generate_dotfile_path(basename) 45 | 46 | if File.exist?(symlink_path) 47 | if File.symlink?(symlink_path) 48 | if File.readlink(symlink_path) == dotfile_path 49 | info "Symlink verified: %s -> %s", symlink_path, dotfile_path 50 | return 51 | else 52 | info "Removing outdated symlink %s", symlink_path 53 | rm(symlink_path) 54 | end 55 | else 56 | info "Found normal file at %s, skipping...", symlink_path 57 | return 58 | end 59 | end 60 | 61 | symlink(dotfile_path, symlink_path) 62 | end 63 | 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/briefcase/main.rb: -------------------------------------------------------------------------------- 1 | require 'highline/import' 2 | require File.expand_path('../briefcase', File.dirname(__FILE__)) 3 | 4 | # In the case of the git passthrough command, we want to pass the complete command 5 | # off to git so the options aren't mangled by Commander. 6 | # 7 | # Initializing a Briefcase::Commands::Git object will eventually call out to 8 | # git using exec, so this script will be aborted. 9 | if ARGV[0] == 'git' 10 | Briefcase::Commands::Git.new(ARGV[1..-1], {}) 11 | end 12 | 13 | require 'commander/import' 14 | 15 | program :name, 'Briefcase' 16 | program :version, Briefcase::VERSION 17 | program :description, 'Makes it easier to keep dotfiles in git' 18 | 19 | command :import do |c| 20 | c.syntax = 'briefcase import PATH' 21 | c.description = 'Move PATH to the version controlled directory and symlink its previous location to its new one.' 22 | c.when_called Briefcase::Commands::Import 23 | end 24 | 25 | command :redact do |c| 26 | c.syntax = 'briefcase redact PATH' 27 | c.description = 'Edit PATH to remove sensitive information, save the edited version to the version controlled directory, and symlink its previous location to its new one, and add to .gitignore.' 28 | c.when_called Briefcase::Commands::Redact 29 | end 30 | 31 | command :sync do |c| 32 | c.syntax = 'briefcase sync' 33 | c.description = 'Updates all symlinks for files included in ~/.dotfiles' 34 | c.when_called Briefcase::Commands::Sync 35 | end 36 | 37 | command :generate do |c| 38 | c.syntax = 'briefcase generate' 39 | c.description = 'Generates static versions of all redacted dotfiles in ~/.dotfiles' 40 | c.when_called Briefcase::Commands::Generate 41 | end 42 | 43 | # This is a placeholder so that this command appears in the documentation. Any git 44 | # passthrough commands are caught earlier in this file. 45 | command :git do |c| 46 | c.syntax = 'briefcase git [options]' 47 | c.description = 'Run a git command in the dotfiles directory' 48 | end 49 | 50 | default_command :help 51 | 52 | # command :suggest do |c| 53 | # c.syntax = 'briefcase suggest' 54 | # c.description 'List dotfiles that are in your home directory and not in ~/.dotfiles' 55 | # c.when_called Briefcase::Commands::Suggest 56 | # end 57 | -------------------------------------------------------------------------------- /lib/briefcase/version.rb: -------------------------------------------------------------------------------- 1 | module Briefcase 2 | VERSION = '0.4.2' 3 | end 4 | -------------------------------------------------------------------------------- /spec/bin/editor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | responses_path = File.expand_path('/tmp/briefcase_spec_work/briefcase_editor_responses', File.dirname(__FILE__)) 4 | response_file = ARGV[0].gsub(/\//, '_') 5 | File.open(ARGV[0], 'w') do |file| 6 | file.write(File.read(File.join(responses_path, response_file))) 7 | end -------------------------------------------------------------------------------- /spec/generate_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Briefcase::Commands::Generate do 4 | 5 | describe "with an existing dotfiles directory" do 6 | 7 | before do 8 | create_dotfiles_directory 9 | create_home_directory 10 | end 11 | 12 | after do 13 | cleanup_dotfiles_directory 14 | cleanup_home_directory 15 | end 16 | 17 | it "generates a static version of a redacted dotfile" do 18 | static_path = File.join(dotfiles_path, 'test') 19 | redacted_path = File.join(dotfiles_path, 'test.redacted') 20 | 21 | create_secrets('test' => {'email' => 'google@internet.com'}) 22 | create_file redacted_path, <<-TEXT 23 | username: # briefcase(email) 24 | favorite_color: blue 25 | TEXT 26 | 27 | run_command("generate") 28 | 29 | output_must_contain(/Generating/, /Loading existing secrets/, /Restoring secret value/) 30 | 31 | file_must_contain static_path, <<-TEXT 32 | username: google@internet.com 33 | favorite_color: blue 34 | TEXT 35 | end 36 | 37 | it "create a secrets file and adds discovered secrets to it" do 38 | static_path = File.join(dotfiles_path, 'test') 39 | redacted_path = File.join(dotfiles_path, 'test.redacted') 40 | 41 | create_file redacted_path, <<-TEXT 42 | username: # briefcase(email) 43 | TEXT 44 | 45 | run_command("generate") 46 | 47 | output_must_contain(/Generating/, /Secret missing for key: email/) 48 | 49 | file_must_contain static_path, <<-TEXT 50 | username: # briefcase(email) 51 | TEXT 52 | 53 | secret_must_be_stored('test', 'email', '') 54 | end 55 | 56 | it "adds discovered secrets to the secrets file without values" do 57 | static_path = File.join(dotfiles_path, 'test') 58 | redacted_path = File.join(dotfiles_path, 'test.redacted') 59 | 60 | create_file redacted_path, <<-TEXT 61 | username: # briefcase(email) 62 | TEXT 63 | create_secrets 64 | 65 | run_command("generate") 66 | 67 | output_must_contain(/Generating/, /Secret missing for key: email/) 68 | 69 | file_must_contain static_path, <<-TEXT 70 | username: # briefcase(email) 71 | TEXT 72 | 73 | secret_must_be_stored('test', 'email', '') 74 | end 75 | 76 | end 77 | 78 | end 79 | -------------------------------------------------------------------------------- /spec/git_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Briefcase::Commands::Git do 4 | 5 | before do 6 | create_dotfiles_directory 7 | create_git_repo 8 | end 9 | 10 | after do 11 | cleanup_dotfiles_directory 12 | end 13 | 14 | it "passes commands through to git" do 15 | create_file(dotfiles_path + '/test.txt', 'testing git integration') 16 | run_command("git status") 17 | 18 | output_must_contain(/Running git status/) 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /spec/helpers/assertions.rb: -------------------------------------------------------------------------------- 1 | def directory_must_exist(path) 2 | File.directory?(path).must_equal(true, "Expected directory to exist at #{path}") 3 | end 4 | 5 | def directory_must_not_exist(path) 6 | File.directory?(path).must_equal(false, "Did not expect directory to exist at #{path}") 7 | end 8 | 9 | def file_must_exist(path) 10 | File.file?(path).must_equal(true, "Expected file to exist at #{path}") 11 | end 12 | 13 | def file_must_contain(path, text) 14 | file_must_exist(path) 15 | # strip trailing whitespace to make it easier to use multiline strings 16 | File.read(path).strip.must_equal(text.strip) 17 | end 18 | 19 | def file_must_not_match(path, regex) 20 | file_must_exist(path) 21 | File.read(path).strip.wont_match(regex) 22 | end 23 | 24 | def file_must_not_exist(path) 25 | File.file?(path).must_equal(false, "Expected file to not exist at #{path}") 26 | end 27 | 28 | def symlink_must_exist(path, target) 29 | File.exist?(path).must_equal(true, "Expected symlink to exist at #{path}") 30 | actual = File.readlink(path) 31 | actual.must_equal(target, "Expected symlink to #{target}, was #{actual}") 32 | end 33 | 34 | def symlink_must_not_exist(path) 35 | if File.exist?(path) 36 | File.file?(path).must_equal true 37 | end 38 | end 39 | 40 | def file_must_have_moved(original_path, new_path) 41 | file_must_exist(new_path) 42 | birthplace = File.open(new_path) do |file| 43 | file.read 44 | end 45 | birthplace.must_equal(original_path, "Expected file at #{new_path} to have been moved from #{original_path}") 46 | end 47 | 48 | def file_must_not_have_moved(path) 49 | file_must_exist(path) 50 | birthplace = File.open(path) { |file| file.read } 51 | birthplace.must_equal(path, "Did not expect file at #{path} to have been moved from #{birthplace}") 52 | end 53 | 54 | def git_ignore_must_include(path) 55 | git_ignore_path = File.join(dotfiles_path, '.gitignore') 56 | file_must_exist(git_ignore_path) 57 | ignore_contents = File.open(git_ignore_path) { |file| file.read } 58 | ignore_contents.must_match %r{^#{File.basename(path)}$} 59 | end 60 | 61 | def secret_must_be_stored(yaml_key, key, value) 62 | file_must_exist(secrets_path) 63 | @secrets = YAML.load_file(secrets_path) 64 | @secrets[yaml_key].wont_equal(nil, "Expected YAML secrets file to contain value for key '#{yaml_key}'") 65 | @secrets[yaml_key][key].must_equal(value) 66 | end 67 | 68 | def array_matches_regex(array, regex) 69 | array.any? do |element| 70 | element.gsub(/\e\[\d+m/, '') =~ regex 71 | end 72 | end 73 | 74 | def output_must_contain(*regexes) 75 | regexes.all? do |regex| 76 | array_matches_regex(@output.lines, regex).must_equal(true, "Could not find #{regex} in: \n#{@output}") 77 | end 78 | end 79 | 80 | def output_must_not_contain(*regexes) 81 | regexes.any? do |regex| 82 | array_matches_regex(@output.lines, regex).must_equal(false, "Found #{regex} in: \n#{@output}") 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/helpers/commands.rb: -------------------------------------------------------------------------------- 1 | require "open4" 2 | 3 | def run_command(command, expected_status=0, &block) 4 | @output = '' 5 | 6 | responses = [] 7 | def responses.response(regex, text) 8 | push([regex, text]) 9 | end 10 | 11 | if block_given? 12 | block.call(responses) 13 | end 14 | 15 | ENV['BRIEFCASE_DOTFILES_PATH'] = dotfiles_path 16 | ENV['BRIEFCASE_HOME_PATH'] = home_path 17 | ENV['BRIEFCASE_SECRETS_PATH'] = secrets_path 18 | ENV['BRIEFCASE_TESTING'] = 'true' 19 | ENV['RUBYOPT'] = 'rubygems' 20 | ENV['BRIEFCASE_EDITOR'] = File.expand_path('../bin/editor', File.dirname(__FILE__)) 21 | 22 | full_command = "./bin/briefcase #{command}" 23 | full_command << " --trace" if ENV['BRIEFCASE_TEST_TRACE'] 24 | 25 | status = Open4.popen4(full_command) do |pid, stdin, stdout, stderr| 26 | while output = stdout.gets() || stderr.gets() 27 | puts output if ENV['BRIEFCASE_VERBOSE_TEST'] 28 | @output << output 29 | responses.each do |response| 30 | regex, text = response 31 | stdin.write(text + "\n") if output =~ regex 32 | end 33 | end 34 | end 35 | 36 | exit_status = status.exitstatus 37 | unless exit_status == expected_status 38 | puts "\n" + @output + "\n" if ENV['BRIEFCASE_TEST_TRACE'] 39 | fail("Expected exist status of #{expected_status}, got #{exit_status}") 40 | end 41 | 42 | end 43 | -------------------------------------------------------------------------------- /spec/helpers/files.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | include FileUtils 3 | 4 | SPEC_ROOT = "/tmp/briefcase_spec_work" 5 | 6 | def home_path 7 | File.expand_path('briefcase_home', SPEC_ROOT) 8 | end 9 | 10 | def dotfiles_path 11 | File.expand_path('briefcase_dotfiles', SPEC_ROOT) 12 | end 13 | 14 | def secrets_path 15 | File.expand_path('.briefcase_secrets', home_path) 16 | end 17 | 18 | def editor_responses_path 19 | File.expand_path('briefcase_editor_responses', SPEC_ROOT) 20 | end 21 | 22 | def create_home_directory 23 | mkdir_p(home_path) 24 | end 25 | 26 | def cleanup_home_directory 27 | rm_rf(home_path) 28 | end 29 | 30 | def create_git_repo 31 | `cd #{dotfiles_path} && git init` 32 | end 33 | 34 | def create_dotfiles_directory 35 | mkdir_p(dotfiles_path) 36 | end 37 | 38 | def create_secrets(hash={}) 39 | File.open(secrets_path, "w") do |file| 40 | file.write(hash.to_yaml) 41 | end 42 | end 43 | 44 | def cleanup_dotfiles_directory 45 | rm_rf(dotfiles_path) 46 | end 47 | 48 | def create_file(path, text='') 49 | File.open(path, "w") do |file| 50 | file.write(text) 51 | end 52 | end 53 | 54 | def create_trackable_file(path) 55 | create_file(path, path) 56 | end 57 | -------------------------------------------------------------------------------- /spec/helpers/stubbing.rb: -------------------------------------------------------------------------------- 1 | def stub_editor_response(file, text) 2 | response_file = file.gsub(/\//, '_') 3 | mkdir_p(editor_responses_path) 4 | File.open(File.join(editor_responses_path, response_file), 'w') do |file| 5 | file.write(text) 6 | end 7 | end 8 | 9 | def cleanup_editor_responses 10 | rm_rf(editor_responses_path) 11 | end 12 | 13 | class Object 14 | def stub(method_name, return_value=nil, &block) 15 | (class << self; self; end).class_eval do 16 | define_method method_name do |*args| 17 | if block_given? 18 | block.call(*args) 19 | else 20 | return_value 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/import_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Briefcase::Commands::Import do 4 | 5 | before do 6 | create_home_directory 7 | 8 | @original_path = File.join(home_path, '.test') 9 | @destination_path = File.join(dotfiles_path, 'test') 10 | end 11 | 12 | after do 13 | cleanup_home_directory 14 | cleanup_dotfiles_directory 15 | cleanup_editor_responses 16 | end 17 | 18 | it "creates a .dotfiles directory if it doesn't exist" do 19 | create_trackable_file(@original_path) 20 | 21 | run_command("import #{@original_path}") do |c| 22 | c.response(/create one now?/, 'create') 23 | end 24 | 25 | output_must_contain(/Creating/) 26 | output_must_contain(/Initialized/) 27 | directory_must_exist(dotfiles_path) 28 | 29 | directory_must_exist(File.join(dotfiles_path, '.git')) 30 | end 31 | 32 | it "does not create a .dotfiles directory when a users cancels" do 33 | create_trackable_file(@original_path) 34 | 35 | run_command("import #{@original_path}", 255) do |c| 36 | c.response(/create one now?/, 'abort') 37 | end 38 | 39 | output_must_not_contain(/Creating/) 40 | output_must_not_contain(/Initializing/) 41 | directory_must_not_exist(dotfiles_path) 42 | end 43 | 44 | describe "with an existing dotfiles directory" do 45 | 46 | before do 47 | create_dotfiles_directory 48 | create_git_repo 49 | end 50 | 51 | after do 52 | cleanup_dotfiles_directory 53 | end 54 | 55 | it "does not import a nonexistent dotfile" do 56 | run_command("import .test", 255) 57 | output_must_contain(/does not exist/) 58 | end 59 | 60 | it "imports a dotfile" do 61 | create_trackable_file(@original_path) 62 | 63 | run_command("import #{@original_path}") 64 | 65 | output_must_contain(/Importing/, /Moving/) 66 | file_must_have_moved(@original_path, @destination_path) 67 | symlink_must_exist(@original_path, @destination_path) 68 | end 69 | 70 | it "imports a redacted dotfile xxx" do 71 | redacted_path = File.join(dotfiles_path, 'test.redacted') 72 | create_file @original_path, <<-TEXT 73 | setting: ABCDEFG 74 | TEXT 75 | 76 | stub_editor_response redacted_path, <<-TEXT 77 | !! Edit the file below, replacing and sensitive information to turn this: 78 | !! 79 | !! password: superSecretPassword 80 | !! 81 | !! Into: 82 | !! 83 | !! password: # briefcase(password) 84 | !! 85 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 86 | setting: # briefcase(token) 87 | TEXT 88 | 89 | run_command("redact #{@original_path}") 90 | 91 | output_must_contain(/Importing/, /Moving/, /Creating redacted version at/, /Storing secret value for key: token/) 92 | secret_must_be_stored('test', 'token', 'ABCDEFG') 93 | symlink_must_exist(@original_path, @destination_path) 94 | file_must_not_match(redacted_path, 'replacing and sensitive information') 95 | git_ignore_must_include(@destination_path) 96 | end 97 | 98 | describe "collision handling" do 99 | 100 | before do 101 | @relocated_path = File.join(dotfiles_path, 'test.old.1') 102 | create_trackable_file(@original_path) 103 | create_trackable_file(@destination_path) 104 | end 105 | 106 | it "renames an existing dotfile when importing a duplicate and instructed to replace it" do 107 | run_command("import #{@original_path}") do |c| 108 | c.response(/Do you want to replace it\?/, 'replace') 109 | end 110 | 111 | output_must_contain(/Moving/, /Symlinking/, /already exists as a dotfile/) 112 | file_must_exist(@destination_path) 113 | file_must_exist(@relocated_path) 114 | symlink_must_exist(@original_path, @destination_path) 115 | end 116 | 117 | it "renames an existing duplicate dotfile when importing a duplicate and instructed to replace it" do 118 | duplicate_path = File.join(dotfiles_path, 'test.old.2') 119 | create_trackable_file(@relocated_path) 120 | 121 | run_command("import #{@original_path}") do |c| 122 | c.response(/Do you want to replace it\?/, 'replace') 123 | end 124 | 125 | file_must_have_moved(@destination_path, duplicate_path) 126 | file_must_not_have_moved(@relocated_path) 127 | end 128 | 129 | it "does not modify an existing dotfile when instructed not to" do 130 | run_command("import #{@original_path}", 255) do |c| 131 | c.response(/Do you want to replace it\?/, 'abort') 132 | end 133 | 134 | output_must_contain(/already exists as a dotfile/) 135 | output_must_not_contain(/Moving/, /Symlinking/) 136 | file_must_not_have_moved(@original_path) 137 | file_must_not_have_moved(@destination_path) 138 | file_must_not_exist(@relocated_path) 139 | symlink_must_not_exist(@original_path) 140 | end 141 | 142 | end 143 | 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | 4 | require 'minitest/spec' 5 | # require 'turn/autorun/minitest' 6 | require 'minitest/mock' 7 | 8 | MiniTest::Unit.autorun 9 | 10 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 11 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 12 | require 'briefcase' 13 | 14 | require 'helpers/assertions' 15 | require 'helpers/files' 16 | require 'helpers/stubbing' 17 | require 'helpers/commands' 18 | -------------------------------------------------------------------------------- /spec/sync_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Briefcase::Commands::Sync do 4 | 5 | before do 6 | create_home_directory 7 | create_dotfiles_directory 8 | 9 | @file_path = File.join(dotfiles_path, 'test') 10 | @link_path = File.join(home_path, '.test') 11 | end 12 | 13 | after do 14 | cleanup_home_directory 15 | cleanup_dotfiles_directory 16 | end 17 | 18 | it "creates links to existing files" do 19 | create_file(@file_path) 20 | 21 | run_command("sync") 22 | 23 | output_must_contain(/Synchronizing dotfiles/, /Symlinking/) 24 | 25 | file_must_exist(@file_path) 26 | symlink_must_exist(@link_path, @file_path) 27 | end 28 | 29 | it "does not create links to existing dynamic files" do 30 | redacted_path = File.join(dotfiles_path, 'test.redacted') 31 | dynamic_link_path = File.join(home_path, '.test.redacted') 32 | create_file(redacted_path) 33 | 34 | run_command("sync") 35 | 36 | output_must_not_contain(/Symlinking/) 37 | file_must_not_exist(dynamic_link_path) 38 | end 39 | 40 | it "handles finding real dotfiles where symlinks would be" do 41 | file_path = File.join(home_path, '.test') 42 | link_path = File.join(dotfiles_path, 'test') 43 | create_file(file_path) 44 | create_file(link_path) 45 | 46 | run_command("sync") 47 | 48 | output_must_not_contain(/Symlinking/) 49 | output_must_contain(/skipping/) 50 | end 51 | 52 | end 53 | --------------------------------------------------------------------------------