summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.org12
-rw-r--r--core.rb85
-rwxr-xr-xdotdot75
-rw-r--r--stdlib.rb54
5 files changed, 227 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b25c15b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+*~
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..f52a687
--- /dev/null
+++ b/README.org
@@ -0,0 +1,12 @@
+#+TILTE: dotdot
+#+SUBTITLE: Programmatic dotfile manager
+
+* Synopsis
+=dotdot= was born as a dotfile manager (though it could be used for many purposes). Fundamentally, its job is:
+1. to evaluate a recipe describing e.g. how to set up =~/=,
+2. check if the recipe can be executed safely, e.g. that there are no files that would be overwritten, that all necessary directories exist,
+3. execute the recipe.
+* Usage
+Currently, =dotdot= is only intended for my personal use, so I haven’t taken time to document it or even rigorously test it. =dotdot= is provided /as is/, and currently you must read the code to understand how it works.
+
+I intend to write documentation for it someday, but it’s too currently too early for that.
diff --git a/core.rb b/core.rb
new file mode 100644
index 0000000..5dad22a
--- /dev/null
+++ b/core.rb
@@ -0,0 +1,85 @@
+class Op
+ def check
+ raise "#{self.class} doesn't have a (required) 'check' method"
+ end
+ def do
+ raise "#{self.class} doesn't have a (required) 'do' method"
+ end
+ def undo
+ raise "#{self.class} doesn't have a (required) 'undo' method"
+ end
+end
+
+def defop(name, args, &block)
+ name = name.to_sym
+ eval "class #{name} < Op; end" # Must use `eval' to access
+ cls = eval "#{name}" # the global namespace
+ cls.instance_eval do
+ args.map {|a| attr_reader a.to_sym }
+ end
+ cls.define_method :initialize do |**kwargs|
+ args.each {|arg| instance_variable_set :"@#{arg}", kwargs[arg.to_sym] }
+ end
+ cls.define_method :to_s do inspect end
+ cls.define_method :to_str do to_s end
+ cls.instance_eval &block
+end
+
+defop :Write, [:file, :contents] do
+ define_method :check do
+ if File.exist? @file
+ if (File.read @file) != @contents
+ raise "File `#{@file}' exists but has unexpected contents"
+ else true
+ end
+ end
+ end
+ define_method :do do
+ write @file, @contents
+ end
+ define_method :undo do
+ rm @file
+ end
+end
+
+defop :Deploy, [:ops, :save] do
+ define_method :initialize do |save: nil, &block|
+ @ops = []
+ @save = save
+ self.instance_eval &block
+ end
+ define_method :check do
+ @ops.all? {|op| op.check } and
+ (@save ? self.save.check : true)
+ end
+ define_method :do do
+ self.load.undo if @save && self.save.check
+ @ops.each {|op| op.check || op.do }
+ self.save.do if @save
+ end
+ define_method :undo do
+ @ops.reverse.each {|op| op.check && op.undo }
+ self.save.undo if @save
+ end
+ define_method :save do
+ (Write.new file: @save,
+ contents: self.to_yaml)
+ end
+ define_method :load do
+ YAML.load_file(
+ @save,
+ permitted_classes: ([Op] + Op.subclasses))
+ end
+end
+
+def deploy(...)
+ Deploy.new(...)
+end
+
+def defdeploy(name, &block)
+ Deploy.define_method name, &block
+end
+
+defdeploy :op do |cls, **args|
+ @ops.push cls.new(**args)
+end
diff --git a/dotdot b/dotdot
new file mode 100755
index 0000000..7b9dffb
--- /dev/null
+++ b/dotdot
@@ -0,0 +1,75 @@
+#!/usr/bin/ruby
+%w[socket fileutils yaml]
+ .map { require _1 }
+
+HOME = ENV["HOME"] or File.expand_path "~/"
+HOSTNAME = Socket.gethostname
+
+class Binding
+ def eval_file(path)
+ self.eval (File.read path)
+ end
+end
+
+def eval_file(file)
+ file = File.expand_path file
+ dir = File.dirname file
+ binding.eval_file file
+end
+
+def ls(dir)
+ Dir.entries(dir).reject {|d| [".", ".."].include? d }
+end
+
+def ln(from, to)
+ FileUtils.ln_s from, to
+end
+
+def write(file, contents)
+ File.write file, contents
+end
+
+def rm(file)
+ FileUtils.rm file
+end
+
+def mkdir(dir)
+ FileUtils.mkdir_p dir
+end
+
+def rmdir(dir)
+ FileUtils.rmdir dir
+end
+
+require_relative "core"
+require_relative "stdlib"
+
+$cmds = {
+ show: lambda {|op|
+ if op.is_a? Deploy
+ puts op.ops
+ else
+ puts op
+ end
+ },
+ debug: lambda {|op|
+ puts op.to_yaml
+ },
+ check: lambda {|op|
+ if op.check
+ puts "nothing left to do"
+ else
+ puts "not fully deployed"
+ end
+ },
+ do: lambda {|op| op.do },
+ undo: lambda {|op| op.undo },
+}
+def main
+ command = ARGV.shift.to_sym
+ file = ARGV.shift || "#{HOSTNAME}.rb"
+ op = eval_file file
+ $cmds[command][op]
+end
+
+main if __FILE__ == $0
diff --git a/stdlib.rb b/stdlib.rb
new file mode 100644
index 0000000..3b414ce
--- /dev/null
+++ b/stdlib.rb
@@ -0,0 +1,54 @@
+defop :MkDir, [:dir] do
+ define_method :check do
+ File.directory? @dir
+ end
+ define_method :do do
+ mkdir @dir
+ end
+ define_method :undo do
+ rmdir @dir if File.exist? @dir and Dir.empty? @dir
+ end
+ define_method :inspect do
+ "#<MkDir '#{@dir}'>"
+ end
+end
+
+defop :SymLink, [:from, :to] do
+ define_method :check do
+ if File.exist? @to or File.symlink? @to # broken symlink should be kept
+ if !(File.symlink? @to) or (File.readlink @to) != @from
+ raise "File '#{@to}' exists but is not a symlink to '#{@from}'"
+ else true
+ end
+ end
+ end
+ define_method :do do
+ ln @from, @to
+ end
+ define_method :undo do
+ rm @to
+ end
+ define_method :inspect do
+ "#<SymLink '#{@from}' -> '#{to}>"
+ end
+end
+
+defdeploy :tree do |src:, dst:, ignores: [/~$/], stops: []|
+ sp, dp = [src, dst].map {|p| File.expand_path p }
+ src = lambda {|path| File.join sp, path }
+ dst = lambda {|path| File.join dp, path }
+ recur = lambda do |path|
+ return if ignores.any? {|pat| path =~ pat }
+ if File.directory?(src[path])
+ if stops.include? path
+ op SymLink, from: src[path], to: dst[path]
+ else
+ op MkDir, dir: dst[path]
+ (ls src[path]).each {|p| recur[File.join path, p] }
+ end
+ else
+ op SymLink, from: src[path], to: dst[path]
+ end
+ end
+ (ls sp).each {|path| recur[path] }
+end