From ddc4ce5f3d6fa49367cca3d8b87d26bec16343a1 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Wed, 11 Nov 2009 15:09:37 +0000 Subject: [PATCH] Initial commit. Feature-complete except for recursing into subdiretories. --- .gitignore | 2 + dotfilemanager.py | 248 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 .gitignore create mode 100755 dotfilemanager.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3d74a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +*~ diff --git a/dotfilemanager.py b/dotfilemanager.py new file mode 100755 index 0000000..906152f --- /dev/null +++ b/dotfilemanager.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python +"""dotfilemanager.py - a dotfiles manager script. See --help for usage +and command-line arguments. + +The idea is that you have some folder called the TO_DIR (defaults to +~/.dotfiles), where you move all the dotfiles that you want to manage, +e.g. + + ~/.dotfiles/ + ~/.dotfiles/_muttrc + ~/.dotfiles/_nanorc + ... + +You can backup and synchronise this directory between multiple hosts +using rsync, unison, a version-control system, Dropbox, or whatever you +want. When you run `dotfilemanager link` it will create symlinks in a +folder called the FROM_DIR (defaults to ~), e.g. + + ~/.muttrc -> ~/.dotfiles/_muttrc + ~/.nanorc -> ~/.dotfiles/_nanorc + ... + +Leading underscores in the filenames in TO_DIR will be converted to +leading dots for the symlinks. You can also link files without leading +underscores, and you can link directories too, just place them in TO_DIR +and run `dotfilemanager link`. + +Per-host configuration is supported by putting __hostname at the end of +file and directory names in TO_DIR. For example if TO_DIR contains files +named: + + _muttrc + _muttrc__kisimul + _muttrc__dulip + +Then on the host dulip a symlink FROM_DIR/.muttrc will be created to +TO_DIR/_muttrc__dulip. On a host named kisimul _muttrc__kisimul will be +linked to. On other hosts _muttrc will be linked to. + +(To discover the hostname of your machine run `uname -n`.) + +`dotfilemanager tidy` will remove any dangling symlinks in FROM_DIR, and +`dotfilemanager report` will just report on what link or tidy would do +without actually making any changes to the filesystem. + +TODO: support recursing into subdirectories, so I can have something +like this in TO_DIR: + + _config + _config/openbox + _config/openbox.kisimul + _config/openbox.debxo + _config/terminator + _config/terminator.dulip + +i.e. host-specific files and directories inside a subdirectory of +TO_DIR. Want to allow for untracked files in the FROM_DIR/.config on the +host, so don't symlink subdirectories of TO_DIR themselves but recurse +into them and symlink any files inside, then recurse into any +subdirectories and repeat. + +""" +import os,sys +import platform +HOSTNAME = platform.node() +HOSTNAME_SEPARATOR = '__' + +def tidy(d,report=False): + """Find and delete any broken symlinks in directory d. + + Arguments: + d -- The directory to consider (absolute path) + + Keyword arguments: + report -- If report is True just report on what broken symlinks are + found, don't attempt to delete them (default: False) + + """ + for f in os.listdir(d): + path = os.path.join(d,f) + if os.path.islink(path): + target_path = os.readlink(path) + if not os.path.isabs(target_path): + # This is a relative symlink, resolve it. + target_path = os.path.join(os.path.dirname(path),target_path) + if not os.path.exists(target_path): + # This is a broken symlink. + if report: + print 'Broken symlink will be deleted: %s->%s' % (path,target_path) + else: + print 'Deleting broken symlink: %s->%s' % (path,target_path) + os.remove(path) + +def get_target_paths(to_dir): + """Return the list of absolute paths to link to for a given to_dir. + + This handles skipping various types of filename in to_dir and + resolving host-specific filenames. + + """ + paths = [] + filenames = os.listdir(to_dir) + for filename in filenames: + path = os.path.join(to_dir,filename) + if filename.endswith('~'): + print 'Skipping %s' % filename + continue + elif filename in ['.gitignore','.git','README','makelinks']: + print 'Skipping %s' % filename + continue + elif (not os.path.isfile(path)) and (not os.path.isdir(path)): + print 'Skipping %s (not a file or directory)' % filename + continue + elif filename.startswith('.'): + print 'Skipping %s (filename has a leading dot)' % filename + continue + else: + if HOSTNAME_SEPARATOR in filename: + # This appears to be a filename with a trailing + # hostname, e.g. _muttrc_dulip. If the trailing hostname + # matches the hostname of this computer then we link to + # it. + hostname = filename.split(HOSTNAME_SEPARATOR)[-1] + if hostname == HOSTNAME: + path = os.path.join(to_dir,filename) + paths.append(path) + else: + print 'Skipping %s (different hostname)' % filename + continue + else: + # This appears to be a filename without a trailing + # hostname. + if filename + HOSTNAME_SEPARATOR + HOSTNAME in filenames: + print 'Skipping %s (there is a host-specific version of this file for this host)' % filename + continue + else: + paths.append(path) + return paths + +def link(from_dir,to_dir,report=False): + """Make symlinks in from_dir to each file and directory in to_dir. + + This handles converting leading underscores in to_dir to leading + dots in from_dir. + + Arguments: + from_dir -- The directory in which symlinks will be created (string, + absolute path) + to_dir -- The directory containing the files and directories that + will be linked to (string, absolute path) + + Keyword arguments: + report -- If report is True then only report on the status of + symlinks in from_dir, don't actually create any new + symlinks (default: False) + + """ + # The paths in to_dir that we will be symlinking to. + to_paths = get_target_paths(to_dir) + + # Dictionary of symlinks we will be creating, from_path->to_path + symlinks = {} + for to_path in to_paths: + to_directory, to_filename = os.path.split(to_path) + # Change leading underscores to leading dots. + if to_filename.startswith('_'): + from_filename = '.' + to_filename[1:] + else: + from_filename = to_filename + # Remove hostname specifiers. + parts = from_filename.split(HOSTNAME_SEPARATOR) + assert len(parts) == 1 or len(parts) == 2 + from_filename = parts[0] + from_path = os.path.join(from_dir,from_filename) + symlinks[from_path] = to_path + + # Attempt to create the symlinks that don't already exist. + for from_path,to_path in symlinks.items(): + # Check that nothing already exists at from_path. + if os.path.islink(from_path): + # A link already exists. + existing_to_path = os.readlink(from_path) + if existing_to_path == to_path: + # It's already a link to the intended target. All is + # well. + continue + else: + # It's a link to somewhere else. + print from_path+" => is already symlinked to "+existing_to_path + elif os.path.isfile(from_path): + print "There's a file in the way at "+from_path + elif os.path.isdir(from_path): + print "There's a directory in the way at "+from_path + elif os.path.ismount(from_path): + print "There's a mount point in the way at "+from_path + else: + # The path is clear, make the symlink. + if report: + print 'A symlink will be made: %s->%s' % (from_path,to_path) + else: + print 'Making symlink %s->%s' % (from_path,to_path) + os.symlink(to_path,from_path) + +def usage(): + return """makelinks [options] link|tidy|report + +Commands: + link -- make symlinks in FROM_DIR to files and directories in TO_DIR + tidy -- remove broken symlinks from FROM_DIR + report -- report on symlinks in FROM_DIR and files and directories in TO_DIR""" + +if __name__ == "__main__": + import optparse + parser = optparse.OptionParser(usage=usage()) + + parser.add_option('-f', '--from', action='store', dest='FROM_DIR',default=os.path.expanduser('~'), help="The directory to create symlinks in.") + parser.add_option('-t', '--to', action='store', dest='TO_DIR',default=os.path.join(os.path.expanduser('~'),'.dotfiles'), help="The directory to create symlinks to.") + + options, remainder = parser.parse_args() + + FROM_DIR = options.FROM_DIR + if not os.path.isdir(FROM_DIR): + print FROM_DIR+" is not a directory!" + parser.print_usage() + sys.exit(2) + + TO_DIR = options.TO_DIR + if not os.path.isdir(TO_DIR): + print TO_DIR+" is not a directory!" + parser.print_usage() + sys.exit(2) + + if len(remainder) != 1: + parser.print_usage() + sys.exit(2) + else: + COMMAND = remainder[0] + + if COMMAND == 'link': + link(FROM_DIR,TO_DIR) + elif COMMAND == 'tidy': + tidy(FROM_DIR) + elif COMMAND == 'report': + link(FROM_DIR,TO_DIR,report=True) + tidy(FROM_DIR,report=True) + else: + parser.print_usage() + sys.exit(2) \ No newline at end of file