geoffreywiseman-prune 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.mdown ADDED
@@ -0,0 +1,24 @@
1
+ # Prune: Deleting and archiving files by date
2
+
3
+ We have some nightly processes that archive information that we'd like to retain, either for reference or
4
+ for possible restoration. In order to keep space usage somewhat reasonable, we'd like to prune some of
5
+ those files as they get older. Prune satisfies that need.
6
+
7
+ Prune is written as a Ruby library with an optional command-line interface and a wrapping shell script.
8
+
9
+ ## Usage
10
+
11
+ Although prune has a command-line interface, at the moment the retention policy which determines what to
12
+ do with each of the files (retain, remove, archive) and how to take action (archiving files by month in
13
+ a .tar.gz) is basically hard-coded for our current needs, although it's easy to change.
14
+
15
+ To invoke prune:
16
+
17
+ bin/prune <folder>
18
+
19
+ To get help on the various command-line options:
20
+
21
+ bin/prune --help
22
+
23
+ In the long run, I expect this will be packaged as a gem and have the retention policy externalized
24
+ where it can be configured for your own needs.
data/Rakefile ADDED
@@ -0,0 +1,45 @@
1
+ require 'rake/clean'
2
+ require 'rake/packagetask'
3
+ require 'rspec/core/rake_task'
4
+ require 'rspec'
5
+ require 'rubygems'
6
+ require 'rubygems/package_task'
7
+
8
+ CLEAN.include( 'coverage', 'pkg' )
9
+
10
+ desc '"spec" (run RSpec)'
11
+ task :default => :spec
12
+
13
+ desc "Run RSpec on spec/*"
14
+ RSpec::Core::RakeTask.new
15
+
16
+ desc "Generate code coverage"
17
+ RSpec::Core::RakeTask.new(:coverage) do |t|
18
+ t.rcov = true
19
+ t.rcov_opts = ['--exclude', 'spec,/gems/,/rubygems/', '--text-report']
20
+ end
21
+
22
+ spec = Gem::Specification.new do |spec|
23
+ spec.name = 'geoffreywiseman-prune'
24
+ spec.version = '1.1.0'
25
+ spec.date = '2011-09-09'
26
+ spec.summary = 'Prunes files from a folder based on a retention policy, often time-based.'
27
+ spec.description = 'Prune is meant to analyze a folder full of files, run them against a retention policy and decide which to keep, which to remove and which to archive. It is extensible and embeddable.'
28
+ spec.author = 'Geoffrey Wiseman'
29
+ spec.email = 'geoffrey.wiseman@codiform.com'
30
+ spec.homepage = 'http://geoffreywiseman.github.com/prune'
31
+ spec.executables << 'prune'
32
+
33
+ spec.files = Dir['{lib,spec}/**/*.rb', 'bin/*', 'Rakefile', 'README.mdown', 'UNLICENSE']
34
+ end
35
+
36
+ Gem::PackageTask.new( spec ) do |pkg|
37
+ pkg.need_tar_gz = true
38
+ pkg.need_zip = true
39
+ end
40
+
41
+ # Rake::PackageTask.new( "prune", "1.1.0" ) do |p|
42
+ # p.need_tar_gz = true
43
+ # p.need_zip = true
44
+ # p.package_files.include( '{bin,lib,spec}/**/*', 'Rakefile', 'README.mdown', 'UNLICENSE' )
45
+ # end
data/UNLICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <http://unlicense.org/>
data/bin/prune ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Include the 'Lib' dir in the Load Path
4
+ # $LOAD_PATH.unshift(File.dirname(__FILE__) + "/../lib")
5
+
6
+ require 'prune'
7
+ Prune::CommandLineInterface.parse_and_run
data/lib/prune.rb ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'prune/cli'
4
+ require 'prune/archiver'
5
+ require 'prune/pruner'
6
+ require 'prune/retention'
7
+ require 'prune/grouper'
8
+ require 'prune/category'
9
+
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/ruby
2
+ require 'rubygems'
3
+ require 'optparse'
4
+ require 'date'
5
+ require 'zlib'
6
+ require 'archive/tar/minitar'
7
+ include Archive::Tar
8
+
9
+ module Prune
10
+ class Archiver
11
+ attr_reader :destination
12
+
13
+ def initialize( destination, source, verbose )
14
+ @source = source
15
+ @verbose = verbose
16
+ @destination = destination || get_default_dir
17
+ end
18
+
19
+ def get_default_dir
20
+ absolute = File.expand_path @source
21
+ path = File.dirname absolute
22
+ name = File.basename absolute
23
+ File.join( path, "#{name}-archives" )
24
+ end
25
+
26
+ def make_destination_dir
27
+ begin
28
+ Dir.mkdir @destination unless File.exists? @destination
29
+ rescue SystemCallError
30
+ raise IOError, "Archive folder #{@destination} does not exist and cannot be created."
31
+ end
32
+ end
33
+
34
+ def archive( group_name, files )
35
+ make_destination_dir
36
+ archive_path = File.join( @destination, "archive-#{group_name}.tar.gz")
37
+ paths = files.map { |file| File.join( @source, file ) }
38
+
39
+ if File.exists?( archive_path ) then
40
+ puts "Archive file #{archive_path} exists." if @verbose
41
+ Dir.mktmpdir do |tmp_dir|
42
+ puts "Created temporary directory #{tmp_dir} to extract contents of existing archive file." if @verbose
43
+ tgz = Zlib::GzipReader.new( File.open( archive_path, 'rb' ) )
44
+ Minitar.unpack( tgz, tmp_dir )
45
+ extracted_paths = Dir.entries( tmp_dir ).map { |tmpfile| File.join( tmp_dir, tmpfile ) }.reject { |path| File.directory? path }
46
+ combined_paths = extracted_paths + paths
47
+ tgz = Zlib::GzipWriter.new( File.open( archive_path, 'wb' ) )
48
+ Minitar.pack( combined_paths, tgz )
49
+ puts "Added #{files.size} file(s) to #{archive_path} archive already containing #{extracted_paths.size} file(s)." if @verbose
50
+ end
51
+ else
52
+ tgz = Zlib::GzipWriter.new( File.open( archive_path, 'wb' ) )
53
+ Minitar.pack( paths, tgz )
54
+ puts "Compressed #{files.size} file(s) into #{archive_path} archive." if @verbose
55
+ end
56
+
57
+ File.delete( *paths )
58
+ puts "Removing #{files.size} source files that have been archived." if @verbose
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ class Category
2
+ attr_accessor :action, :description
3
+
4
+ def initialize( description, action, quiet = false, predicate = Proc.new { |x| true } )
5
+ @description = description
6
+ @action = action
7
+ @predicate = predicate
8
+ @quiet = quiet
9
+ end
10
+
11
+ def requires_prompt?
12
+ case @action
13
+ when :remove
14
+ true
15
+ when :archive
16
+ true
17
+ else
18
+ false
19
+ end
20
+ end
21
+
22
+ def includes?( filename )
23
+ @predicate.call filename
24
+ end
25
+
26
+ def quiet?
27
+ @quiet
28
+ end
29
+
30
+ end
data/lib/prune/cli.rb ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/ruby
2
+ require 'rubygems'
3
+ require 'optparse'
4
+ require 'date'
5
+ require 'zlib'
6
+ require 'archive/tar/minitar'
7
+ include Archive::Tar
8
+
9
+ module Prune
10
+ VERSION = [1,0,0]
11
+
12
+ class CommandLineInterface
13
+
14
+ DEFAULT_OPTIONS = { :verbose => false, :did_work => false, :dry_run => false, :prompt => true, :archive => true }
15
+
16
+ def self.parse_and_run
17
+ options = DEFAULT_OPTIONS.dup
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: prune [options] folder"
20
+ opts.on( "-v", "--verbose", "Prints much more frequently during execution about what it's doing." ) { options[:verbose] = true }
21
+ opts.on( "-d", "--dry-run", "Categorizes files, but does not take any actions on them." ) { options[:dry_run] = true }
22
+ opts.on( "-f", "--force", "--no-prompt", "Will take action without asking permissions; useful for automation." ) { options[:prompt] = false }
23
+ opts.on( "-a", "--archive-folder FOLDER", "The folder in which archives should be stored; defaults to <folder>/../<folder-name>-archives." ) { |path| options[:archive_path] = path }
24
+ opts.on( "--no-archive", "Don't perform archival; typically if the files you're pruning are already compressed." ) { options[:archive] = false }
25
+ opts.on_tail( "--version", "Displays version information." ) do
26
+ options[:did_work] = true
27
+ print "Prune #{VERSION.join('.')}, by Geoffrey Wiseman."
28
+ end
29
+ opts.on_tail( "-?", "--help", "Shows quick help about using prune." ) do
30
+ options[:did_work] = true
31
+ puts opts
32
+ end
33
+ end
34
+
35
+ begin
36
+ parser.parse!
37
+
38
+ if ARGV.size != 1 then
39
+ print parser.help unless options[:did_work]
40
+ else
41
+ Pruner.new( options ).prune( ARGV.first )
42
+ end
43
+ rescue OptionParser::ParseError
44
+ $stderr.print "Error: " + $! + "\n"
45
+ end
46
+
47
+ end
48
+ end
49
+
50
+ end
@@ -0,0 +1,36 @@
1
+ preprocess do |file|
2
+ file.modified_time = File.mtime( file.name )
3
+
4
+ modified_date = Date.parse modified_time.to_s
5
+ file.days_since_modified = Date.today - modified_date
6
+ file.months_since_modified = ( Date.today.year - modified_date.year ) * 12 + (Date.today.month - modified_date.month)
7
+ end
8
+
9
+ category "Ignoring directories" do
10
+ match { |file| File.directory?(file.name) }
11
+ ignore
12
+ quiet
13
+ end
14
+
15
+ category "Retaining Files from the Last Two Weeks" do
16
+ match do |file|
17
+ file.days_since_modified <= 14
18
+ end
19
+ retain
20
+ end
21
+
22
+ category "Retaining 'Friday' files Older than Two Weeks" do
23
+ match { |file| file.modified_time.wday == 5 && file.months_since_modified < 2 && file.days_since_modified > 14 }
24
+ retain
25
+ end
26
+
27
+ category "Removing 'Non-Friday' files Older than Two Weeks" do
28
+ match { |file| file.modified_time.wday != 5 && file.days_since_modified > 14 }
29
+ remove
30
+ end
31
+
32
+ category "Archiving Files Older than Two Months" do
33
+ match { |file| file.modified_time.wday == 5 && file.months_since_modified >= 2 }
34
+ archive
35
+ end
36
+
@@ -0,0 +1,27 @@
1
+ module Prune
2
+ class Grouper
3
+
4
+ def initialize( archiver )
5
+ @groups = Hash.new{ |h,k| h[k] = [] }
6
+ @archiver = archiver
7
+ end
8
+
9
+ def group( folder_name, files )
10
+ files.each do |file|
11
+ mtime = File.mtime( File.join( folder_name, file ) )
12
+ month_name = Date::ABBR_MONTHNAMES[ mtime.month ]
13
+ group_name = "#{month_name}-#{mtime.year}"
14
+ @groups[ group_name ] << file
15
+ end
16
+ return self
17
+ end
18
+
19
+ def archive
20
+ @groups.each_pair do |month,files|
21
+ @archiver.archive( month, files )
22
+ end
23
+ sizes = @groups.values.map { |x| x.size }.join( ', ' )
24
+ "#{@groups.size} archive(s) created (#{sizes} file(s), respectively)"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/ruby
2
+ require 'rubygems'
3
+ require 'optparse'
4
+ require 'date'
5
+ require 'zlib'
6
+ require 'archive/tar/minitar'
7
+ include Archive::Tar
8
+
9
+ module Prune
10
+
11
+ class Pruner
12
+ attr_reader :categories
13
+ attr_reader :options
14
+
15
+ def initialize( options )
16
+ @options = options
17
+ @categories = Hash.new { |h,k| h[k] = [] } # initialize new keys with an empty array
18
+ @analyzed_count = 0
19
+ end
20
+
21
+ def prune( folder_name )
22
+ return print( "ERROR: Cannot find folder: #{folder_name}\n" ) unless File.exists? folder_name
23
+ return puts( "ERROR: #{folder_name} is not a folder" ) unless File.directory? folder_name
24
+ policy = RetentionPolicy.new folder_name
25
+ return puts( "ERROR: Retention policy contains no categories." ) if policy.categories.empty?
26
+ policy.categories.each { |cat| @categories[cat] = Array.new } # retain category order
27
+ analyze folder_name, policy
28
+ execute_prune( folder_name, policy ) unless @options[:dry_run]
29
+ end
30
+
31
+ def analyze( folder_name, policy )
32
+ print "Analyzing '#{folder_name}':\n"
33
+ files = Dir.entries( folder_name ).sort_by { |f| test(?M, File.join( folder_name, f ) ) }
34
+ files.each do |file|
35
+ analyze_file( policy, file )
36
+ end
37
+ print "\n" if @options[:verbose]
38
+
39
+ display_categories policy
40
+ print "\t#{@analyzed_count} file(s) analyzed\n"
41
+ end
42
+
43
+ def execute_prune( folder_name, policy )
44
+ begin
45
+ if should_prompt?( policy ) && !prompt then
46
+ puts "Not proceeding; no actions taken."
47
+ else
48
+ take_all_actions( folder_name, policy )
49
+ end
50
+ rescue IOError
51
+ $stderr.print "ERROR: #{$!}\n"
52
+ end
53
+ end
54
+
55
+ def should_prompt?( policy )
56
+ @options[:prompt] && actions_require_prompt( policy )
57
+ end
58
+
59
+ def actions_require_prompt( policy )
60
+ @categories.keys.any? { |category| category.requires_prompt? }
61
+ end
62
+
63
+ def prompt
64
+ print "Proceed? [y/N]: "
65
+ response = STDIN.gets.chomp.strip.downcase
66
+ ['y','yes','true'].include? response
67
+ end
68
+
69
+ def take_all_actions( folder_name, policy )
70
+ actions = 0
71
+ @categories.each_pair do |category,files|
72
+ action = category.action
73
+ result = take_action( action, folder_name, files )
74
+ if !result.nil? then
75
+ puts result
76
+ actions += 1
77
+ end
78
+ end
79
+ print "No actions necessary.\n" if actions == 0
80
+ end
81
+
82
+ def take_action( action, folder_name, files )
83
+ case action
84
+ when :remove
85
+ paths = files.map { |file| File.join folder_name, file }
86
+ begin
87
+ File.delete *paths
88
+ "#{files.size} file(s) deleted"
89
+ rescue
90
+ raise IOError, "Could not remove file(s): #{$!}"
91
+ end
92
+ when :archive
93
+ if @options[:archive] then
94
+ archiver = Archiver.new( @options[:archive_path], folder_name, @options[:verbose] )
95
+ grouper = Grouper.new( archiver )
96
+ grouper.group( folder_name, files );
97
+ grouper.archive
98
+ else
99
+ "Archive option disabled. Archive(s) not created."
100
+ end
101
+ end
102
+ end
103
+
104
+ def display_categories( policy )
105
+ @categories.each_pair do |category,files|
106
+ if !category.quiet? || @options[:verbose] then
107
+ print "\t#{category.description}:\n\t\t"
108
+ puts files.join( "\n\t\t")
109
+ end
110
+ end
111
+ end
112
+
113
+ def analyze_file( policy, file )
114
+ category = policy.categorize( file )
115
+ @categories[ category ] << file unless category.nil?
116
+ @analyzed_count += 1
117
+ end
118
+ end
119
+
120
+ end
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/ruby
2
+ require 'rubygems'
3
+ require 'date'
4
+ require 'pathname'
5
+
6
+ module Prune
7
+
8
+ class RetentionPolicy
9
+
10
+ attr_accessor :categories
11
+
12
+ def initialize( folder_name )
13
+ @folder_name = folder_name
14
+ @today = Date.today
15
+ @categories = Array.new
16
+ @default_category = Category.new "Unmatched Files", :retain, true
17
+ instance_eval *get_retention_dsl( folder_name )
18
+ end
19
+
20
+ def categorize( file_name )
21
+ file_context = FileContext.new( @folder_name, file_name, @preprocessor )
22
+ @categories.find { |cat| cat.includes? file_context } || @default_category
23
+ end
24
+
25
+ def get_retention_dsl( folder_name )
26
+ get_dsl( folder_name, '.prune' ) || get_dsl( File.dirname(__FILE__), 'default_retention.rb', 'core retention policy' )
27
+ end
28
+
29
+ def get_dsl( dsl_folder, dsl_file, human_name=nil )
30
+ dsl = File.join( dsl_folder, dsl_file )
31
+ human_name = Pathname.new( dsl ).cleanpath.to_s if human_name.nil?
32
+ if File.exists?( dsl ) then
33
+ puts "Loading retention policy from: #{human_name}"
34
+ return File.read( dsl ), dsl_file
35
+ else
36
+ return nil
37
+ end
38
+ end
39
+
40
+ def category( description, &block )
41
+ builder = CategoryBuilder.new( description )
42
+ builder.instance_eval &block
43
+ @categories << builder.build
44
+ end
45
+
46
+ def preprocess( &block )
47
+ @preprocessor = Proc.new &block
48
+ end
49
+
50
+ end
51
+
52
+ class CategoryBuilder
53
+
54
+ def initialize( description )
55
+ @description = description
56
+ @quiet = false
57
+ end
58
+
59
+ def build
60
+ if @predicate.nil? then
61
+ raise "Category #{@description} has no predicate defined."
62
+ elsif @action.nil? then
63
+ raise "Category #{@description} has no action defined."
64
+ end
65
+ Category.new( @description, @action, @quiet, @predicate )
66
+ end
67
+
68
+ def match( &block )
69
+ @predicate = Proc.new &block
70
+ end
71
+
72
+ def ignore
73
+ @action = :ignore
74
+ end
75
+
76
+ def retain
77
+ @action = :retain
78
+ end
79
+
80
+ def archive
81
+ @action = :archive
82
+ end
83
+
84
+ def remove
85
+ @action = :remove
86
+ end
87
+
88
+ def quiet
89
+ @quiet = true
90
+ end
91
+
92
+ end
93
+
94
+ class FileContext
95
+ attr_accessor :name
96
+
97
+ def initialize( path, filename, preprocessor )
98
+ @name = File.join( path, filename )
99
+ @attributes = Hash.new
100
+ instance_eval &preprocessor unless preprocessor.nil?
101
+ end
102
+
103
+ # def responds_to?( symbol )
104
+ # symbol.to_s.end_with? '=' || @attributes.has_key? symbol
105
+ # end
106
+ #
107
+ def method_missing( symbol, *arguments )
108
+ if symbol.to_s =~ /(.+)=/ && arguments.size == 1 then
109
+ @attributes[ $1.to_sym ] = arguments.first
110
+ elsif @attributes.has_key?( symbol ) && arguments.empty? then
111
+ @attributes[ symbol ]
112
+ else
113
+ super symbol, arguments
114
+ end
115
+ end
116
+
117
+ end
118
+
119
+ end
@@ -0,0 +1,103 @@
1
+ require 'prune/archiver'
2
+ require 'tmpdir'
3
+ require 'rspec'
4
+
5
+ describe Prune::Archiver do
6
+ SOURCE='/mysql'
7
+
8
+ describe "with a #{SOURCE} source" do
9
+ DESTINATION = '/mysql-archives'
10
+
11
+ subject { Prune::Archiver.new( nil, '/mysql', true ) }
12
+
13
+ it "should have #{DESTINATION} destination" do
14
+ subject.destination.should eq( DESTINATION )
15
+ end
16
+
17
+ it "should create #{DESTINATION} if does not exist" do
18
+ File.stub( :exists? ).with( DESTINATION ) { false }
19
+ Dir.should_receive( :mkdir ).with( DESTINATION )
20
+ subject.make_destination_dir
21
+ end
22
+
23
+ it "should not attempt to create #{DESTINATION} if it exists" do
24
+ File.stub( :exists? ).with( DESTINATION ) { true }
25
+ Dir.should_not_receive( :mkdir )
26
+ subject.make_destination_dir
27
+ end
28
+
29
+ context "for May 2011" do
30
+ ARCHIVE_FILE="#{DESTINATION}/archive-May-2011.tar.gz"
31
+
32
+ it "should write new archive file in #{DESTINATION} if none exists" do
33
+
34
+ # Destination Exists
35
+ File.stub( :exists? ).with( DESTINATION ) { true }
36
+
37
+ # Archive File Exists
38
+ File.stub( :exists? ).with( ARCHIVE_FILE ) { false }
39
+
40
+ # Create Zip File
41
+ archive_file = double "file"
42
+ gz = double "GzipWriter"
43
+ paths = [ "/mysql/a", "/mysql/b", "/mysql/c" ]
44
+ File.stub( :open ).with( ARCHIVE_FILE, 'wb' ) { archive_file }
45
+ Zlib::GzipWriter.stub( :new ) { gz }
46
+ Minitar.should_receive( :pack ).with( paths, gz )
47
+ File.should_receive( :delete ).with( *paths )
48
+
49
+ subject.archive "May-2011", ["a", "b", "c"]
50
+ end
51
+
52
+ it "should add to existing archive file if it exists" do
53
+
54
+ # Destination Exists
55
+ File.stub( :exists? ).with( DESTINATION ) { true }
56
+
57
+ # Archive File Exists
58
+ File.stub( :exists? ).with( ARCHIVE_FILE ) { true }
59
+
60
+ # Should Create Temp Dir
61
+ tmpdir = "/tmp"
62
+ Dir.stub( :mktmpdir ).and_yield( tmpdir )
63
+
64
+ # Should Extract Contents
65
+ archive_file = double "archive file"
66
+ File.stub( :open ).with( ARCHIVE_FILE, 'rb' ) { archive_file }
67
+ gzr = double "GzipReader"
68
+ Zlib::GzipReader.stub( :new ) { gzr }
69
+ Minitar.should_receive( :unpack ).with( gzr, tmpdir )
70
+ Dir.should_receive( :entries ).with( tmpdir ) { ["c", "d"] }
71
+ extracted_paths = [ "/tmp/c", "/tmp/d" ]
72
+ extracted_paths.each { |path| File.stub( :directory? ).with( path ).and_return( false ) }
73
+
74
+ # Should Create Final Archive
75
+ File.stub( :open ).with( ARCHIVE_FILE, 'wb' ) { archive_file }
76
+ gzw = double "GzipWriter"
77
+ Zlib::GzipWriter.stub( :new ) { gzw }
78
+ original_paths = [ "/mysql/a", "/mysql/b" ]
79
+ combined_paths = extracted_paths + original_paths
80
+ Minitar.should_receive( :pack ).with( combined_paths, gzw )
81
+
82
+ # Delete Files
83
+ File.should_receive( :delete ).with( *original_paths )
84
+
85
+ # Go
86
+ subject.archive "May-2011", ["a", "b"]
87
+
88
+ end
89
+
90
+ end
91
+
92
+ context "and a /mysql/archives destination" do
93
+ subject { Prune::Archiver.new( '/mysql/archives', '/mysql', true ) }
94
+
95
+ it "should use the explicit destination" do
96
+ subject.destination.should eq( '/mysql/archives' )
97
+ end
98
+
99
+ end
100
+
101
+ end
102
+
103
+ end
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,205 @@
1
+ require 'prune/cli'
2
+ require 'prune/pruner'
3
+ require 'spec_helper'
4
+ require 'rspec'
5
+
6
+ describe Prune::CommandLineInterface do
7
+ USAGE_TEXT = /Usage: prune \[options\] folder/
8
+ ARCHIVE_PATH = "/prune/fake/archive-path"
9
+
10
+ before(:each) do
11
+ @messages = []
12
+ $stdout.stub( :write ) { |message| @messages << message }
13
+
14
+ @pruner = double( "pruner" )
15
+ end
16
+
17
+ describe "with no arguments" do
18
+
19
+ it "should print help" do
20
+ ARGV.clear
21
+ Prune::CommandLineInterface::parse_and_run
22
+ @messages.should include_match( USAGE_TEXT )
23
+ end
24
+
25
+ end
26
+
27
+ describe "with a path argument" do
28
+ PATH = "/prune/fake/path"
29
+
30
+ before(:each) do
31
+ ARGV.clear.push( PATH )
32
+ @pruner.stub(:prune)
33
+ end
34
+
35
+ it "should call prune with path" do
36
+ Prune::Pruner.stub( :new ) { @pruner }
37
+ @pruner.should_receive( :prune ).with( PATH )
38
+ Prune::CommandLineInterface::parse_and_run
39
+ end
40
+
41
+ it "should create pruner with defaults" do
42
+ Prune::Pruner.stub( :new ).with( Prune::CommandLineInterface::DEFAULT_OPTIONS ) { @pruner }
43
+ Prune::CommandLineInterface::parse_and_run
44
+ end
45
+
46
+ describe "and a -v argument" do
47
+ it "should set verbose option" do
48
+ assert_arg_to_option( "-v", :verbose => true )
49
+ end
50
+ end
51
+
52
+ describe "and a --verbose argument" do
53
+ it "should set verbose option" do
54
+ assert_arg_to_option( "--verbose", :verbose => true )
55
+ end
56
+ end
57
+
58
+ describe "and a -d argument" do
59
+ it "should set the dry-run option" do
60
+ assert_arg_to_option( "-d", :dry_run => true )
61
+ end
62
+ end
63
+
64
+ describe "and a --dry-run argument" do
65
+ it "should set the dry-run option" do
66
+ assert_arg_to_option( "--dry-run", :dry_run => true )
67
+ end
68
+ end
69
+
70
+ describe "and a -f argument" do
71
+ it "should set the prompt option to false" do
72
+ assert_arg_to_option "-f", :prompt => false
73
+ end
74
+ end
75
+
76
+ describe "and a --force argument" do
77
+ it "should set the prompt option to false" do
78
+ assert_arg_to_option "--force", :prompt => false
79
+ end
80
+ end
81
+
82
+ describe "and a --no-prompt argument" do
83
+ it "should set the prompt option to false" do
84
+ assert_arg_to_option "--no-prompt", :prompt => false
85
+ end
86
+ end
87
+
88
+ describe "and a -a argument" do
89
+ before(:each) do
90
+ ARGV.push "-a"
91
+ end
92
+
93
+ describe "with no folder name" do
94
+ it "should print a parsing error" do
95
+ $stderr.stub( :print ) { |message| @messages << message }
96
+ Prune::CommandLineInterface::parse_and_run
97
+ @messages.should include_match( /missing argument: -a/ )
98
+ end
99
+ end
100
+
101
+ describe "with a folder name" do
102
+ it "should set the archive_path option to the folder" do
103
+ assert_arg_to_option ARCHIVE_PATH, :archive_path => ARCHIVE_PATH
104
+ end
105
+ end
106
+ end
107
+
108
+ describe "and a --archive-folder argument" do
109
+ before(:each) do
110
+ ARGV.push "--archive-folder"
111
+ end
112
+
113
+ describe "with no folder name" do
114
+ it "should print a parsing error" do
115
+ $stderr.stub( :print ) { |message| @messages << message }
116
+ Prune::CommandLineInterface::parse_and_run
117
+ @messages.should include_match( /missing argument: --archive-folder/ )
118
+ end
119
+ end
120
+
121
+ describe "with a folder name" do
122
+ it "should set the archive_path option to the folder" do
123
+ assert_arg_to_option ARCHIVE_PATH, :archive_path => ARCHIVE_PATH
124
+ end
125
+ end
126
+ end
127
+
128
+ describe "and a -? argument" do
129
+
130
+ before(:each) do
131
+ ARGV.push "-?"
132
+ end
133
+
134
+ it "should print help" do
135
+ Prune::CommandLineInterface::parse_and_run
136
+ @messages.should include_match( USAGE_TEXT )
137
+ end
138
+
139
+ it "should not invoke prune" do
140
+ @pruner.should_not_receive( :prune )
141
+ Prune::CommandLineInterface::parse_and_run
142
+ end
143
+
144
+ end
145
+
146
+ describe "and a --help argument" do
147
+
148
+ before(:each) do
149
+ ARGV.push "--help"
150
+ end
151
+
152
+ it "should print help" do
153
+ Prune::CommandLineInterface::parse_and_run
154
+ @messages.should include_match( USAGE_TEXT )
155
+ end
156
+
157
+ it "should not invoke prune" do
158
+ @pruner.should_not_receive( :prune )
159
+ Prune::CommandLineInterface::parse_and_run
160
+ end
161
+
162
+ end
163
+
164
+ describe "and an unknown argument" do
165
+ it "should print a parsing error" do
166
+ ARGV.push "--unknown-argument"
167
+ $stderr.stub( :print ) { |message| @messages << message }
168
+ Prune::CommandLineInterface::parse_and_run
169
+ @messages.should include_match( /invalid option/ )
170
+ end
171
+ end
172
+
173
+ describe "and a --version argument" do
174
+ before(:each) do
175
+ ARGV.push "--version"
176
+ end
177
+
178
+ it "should print version number" do
179
+ $stderr.stub( :print ) { |message| @messages << message }
180
+ Prune::CommandLineInterface::parse_and_run
181
+ @messages.should include_match( /Prune 1.0.0/ )
182
+ end
183
+
184
+ it "should not invoke prune" do
185
+ @pruner.should_not_receive( :prune )
186
+ Prune::CommandLineInterface::parse_and_run
187
+ end
188
+
189
+ end
190
+
191
+ describe "and a --no-archive argument" do
192
+ it "should set the archive option to false" do
193
+ assert_arg_to_option "--no-archive", :archive=>false
194
+ end
195
+ end
196
+
197
+ def assert_arg_to_option( arg, *options )
198
+ Prune::Pruner.should_receive( :new ).with( hash_including( *options ) ).and_return( @pruner )
199
+ ARGV.push( arg )
200
+ Prune::CommandLineInterface::parse_and_run
201
+ end
202
+
203
+ end
204
+
205
+ end
@@ -0,0 +1,63 @@
1
+ require 'prune/grouper'
2
+ require 'prune/retention'
3
+ require 'spec_helper'
4
+ require 'rspec'
5
+
6
+ describe "Prune::Grouper" do
7
+
8
+ GROUP_PATH = '/example/prune/folder'
9
+ before( :each ) do
10
+ @archiver = double( "Archiver" )
11
+ @grouper = Prune::Grouper.new( @archiver )
12
+ end
13
+
14
+ context "w/o files" do
15
+
16
+ it "should not archive" do
17
+ @archiver.should_not_receive( :archive )
18
+ @grouper.group( GROUP_PATH, [] ).archive
19
+ end
20
+
21
+ end
22
+
23
+ context "with files" do
24
+
25
+ it "should archive files" do
26
+ files = mock_files( Date.new(2011,01,01) )
27
+ @archiver.should_receive( :archive )
28
+ @grouper.group( GROUP_PATH, files ).archive
29
+ end
30
+
31
+ it "should specify month and year" do
32
+ files = mock_files( Date.new(2008,03,01) )
33
+ @archiver.should_receive( :archive ).with( "Mar-2008", files )
34
+ @grouper.group( GROUP_PATH, files ).archive
35
+ end
36
+
37
+ it "should combine files with same month/year" do
38
+ files = mock_files( Date.new(2008,03,01), Date.new(2008,03,02) )
39
+ @archiver.should_receive( :archive ).with( "Mar-2008", files )
40
+ @grouper.group( GROUP_PATH, files ).archive
41
+ end
42
+
43
+ it "should not combine files with same month, different year" do
44
+ files = mock_files( Date.new(2008,03,01), Date.new(2009,03,01) )
45
+ @archiver.should_receive( :archive ).with( "Mar-2008", [files.first] )
46
+ @archiver.should_receive( :archive ).with( "Mar-2009", [files.last] )
47
+ @grouper.group( GROUP_PATH, files ).archive
48
+ end
49
+
50
+ end
51
+
52
+ def mock_files( *dates )
53
+ files = []
54
+ dates.map do |item|
55
+ month_name = Date::ABBR_MONTHNAMES[item.month]
56
+ file_name = "file-#{month_name}-#{item.year}.sql"
57
+ File.stub( :mtime ).with( "#{GROUP_PATH}/#{file_name}" ) { item }
58
+ files << file_name
59
+ end
60
+ return files;
61
+ end
62
+
63
+ end
@@ -0,0 +1,206 @@
1
+ require 'prune'
2
+ require 'spec_helper'
3
+ require 'rspec'
4
+
5
+ describe Prune::Pruner do
6
+
7
+ PRUNE_PATH = '/example/prune/folder'
8
+ subject { Prune::Pruner.new Hash.new }
9
+
10
+ before( :each ) do
11
+ @retention_policy = double( "RetentionPolicy" )
12
+ @retention_policy.stub( :categories ) { [ Category.new( "Unmatched Files", :retain, true ) ] }
13
+ Prune::RetentionPolicy.stub( :new ) { @retention_policy }
14
+ end
15
+
16
+ context "w/o prompt" do
17
+ before( :each ) do
18
+ subject.options[:prompt]=false
19
+ end
20
+
21
+ it "should not attempt to process folder that does not exist" do
22
+ File.stub( :exists? ).with( PRUNE_PATH ) { false }
23
+ Dir.should_not_receive( :foreach )
24
+ $stdout.should_receive( :write ).with( /ERROR: Cannot find folder/ )
25
+ subject.prune( PRUNE_PATH )
26
+ end
27
+
28
+ context "with no files" do
29
+
30
+ before( :each ) do
31
+ stub_files
32
+ stub_messages
33
+ subject.prune PRUNE_PATH
34
+ end
35
+
36
+ it "should not invoke the retention policy" do
37
+ @retention_policy.should_not_receive( :categorize )
38
+ end
39
+
40
+ it "should print 'Analyzing #{PRUNE_PATH}'" do
41
+ @messages.should include("Analyzing '#{PRUNE_PATH}':\n")
42
+ end
43
+
44
+ it "should say no action was required" do
45
+ @messages.should include("No actions necessary.\n")
46
+ end
47
+
48
+ it "should say no files were analyzed" do
49
+ @messages.should include_match( /0 file\(s\) analyzed/ )
50
+ end
51
+
52
+ end
53
+
54
+ context "with three files" do
55
+ ONE_DAY = 86400
56
+
57
+ before( :each ) do
58
+ stub_files "beta.txt" => Time.now - ONE_DAY, "alpha.txt" => Time.now - 3*ONE_DAY, "gamma.txt" => Time.now
59
+ stub_messages
60
+ end
61
+
62
+ it "should categorize each file in modified order" do
63
+ @retention_policy.should_receive( :categorize ).with( 'alpha.txt' ).ordered
64
+ @retention_policy.should_receive( :categorize ).with( 'beta.txt' ).ordered
65
+ @retention_policy.should_receive( :categorize ).with( 'gamma.txt' ).ordered
66
+ subject.prune PRUNE_PATH
67
+ end
68
+
69
+ it "should say three files were analyzed" do
70
+ @retention_policy.as_null_object
71
+ subject.prune PRUNE_PATH
72
+ @messages.should include_match( /3 file\(s\) analyzed/ )
73
+ end
74
+
75
+ end
76
+
77
+ context "with file categorized as :remove" do
78
+ it "should delete file" do
79
+ filename = 'delete-me.txt'
80
+ stub_files filename
81
+
82
+ category = double( category )
83
+ category.stub( :description ) { "Old" }
84
+ category.stub( :action ) { :remove }
85
+ category.stub( :quiet? ) { false }
86
+
87
+ @retention_policy.should_receive( :categorize ).with( filename ) { category }
88
+ File.should_receive( :delete ).with( File.join( PRUNE_PATH, filename ) )
89
+ subject.prune PRUNE_PATH
90
+ end
91
+ end
92
+
93
+ context "with files categorized as :archive" do
94
+ let!(:files) { [ 'one.tar.gz', 'two.tar.gz', 'three.tar.gz' ] }
95
+
96
+ before do
97
+ subject.options[:archive] = true
98
+ stub_files files
99
+ end
100
+
101
+ it "should archive files in groups" do
102
+ category = double( "category" )
103
+ @retention_policy.stub( :categorize ) { category }
104
+ category.stub( :action ) { :archive }
105
+ category.stub( :description ) { "Ancient" }
106
+ category.stub( :quiet? ) { false }
107
+
108
+ grouper = double( "Grouper" )
109
+ Prune::Grouper.stub( :new ) { grouper }
110
+ grouper.should_receive( :group ).with( PRUNE_PATH, files )
111
+ grouper.should_receive( :archive ) { "2 Archives created." }
112
+
113
+ subject.prune PRUNE_PATH
114
+ end
115
+ end
116
+
117
+ end
118
+
119
+ describe "Confirmation Prompt" do
120
+ before( :each ) do
121
+ subject.options[:prompt]=false
122
+ end
123
+
124
+ it "should interpret 'Y' as true" do
125
+ expect_prompt_with_response( "Y\n")
126
+ subject.prompt.should be_true
127
+ end
128
+
129
+ it "should interpret 'Y ' as true" do
130
+ expect_prompt_with_response("Y \n")
131
+ subject.prompt.should be_true
132
+ end
133
+
134
+ it "should interpret ' Y' as true" do
135
+ expect_prompt_with_response(" Y\n")
136
+ subject.prompt.should be_true
137
+ end
138
+
139
+ it "should interpret ' Y ' as true" do
140
+ expect_prompt_with_response(" Y \n")
141
+ subject.prompt.should be_true
142
+ end
143
+
144
+ it "should interpret 'y' as true" do
145
+ expect_prompt_with_response("y\n")
146
+ subject.prompt.should be_true
147
+ end
148
+
149
+ it "should interpret 'yes' as true" do
150
+ expect_prompt_with_response("yes\n")
151
+ subject.prompt.should be_true
152
+ end
153
+
154
+ it "should interpret 'no' as false" do
155
+ expect_prompt_with_response("no\n")
156
+ subject.prompt.should be_false
157
+ end
158
+
159
+ it "should interpret 'n' as false" do
160
+ expect_prompt_with_response("n\n")
161
+ subject.prompt.should be_false
162
+ end
163
+
164
+ it "should interpret 'N' as false" do
165
+ expect_prompt_with_response("N\n")
166
+ subject.prompt.should be_false
167
+ end
168
+
169
+ it "should interpret 'q' as false" do
170
+ expect_prompt_with_response("q\n")
171
+ subject.prompt.should be_false
172
+ end
173
+
174
+ def expect_prompt_with_response( response )
175
+ $stdout.should_receive( :write ).with( /Proceed?/ )
176
+ STDIN.stub(:gets) { response }
177
+ end
178
+
179
+ end
180
+
181
+ def stub_files( files = nil )
182
+ File.stub( :exists? ).with( PRUNE_PATH ) { true }
183
+ File.stub( :directory? ).with( PRUNE_PATH ) { true }
184
+ case files
185
+ when nil
186
+ Dir.stub( :entries ).with( PRUNE_PATH ) { Array.new }
187
+ when String
188
+ subject.stub(:test).with( ?M, File.join( PRUNE_PATH, files ) ) { Time.now }
189
+ Dir.stub( :entries ).with( PRUNE_PATH ) { [ files ] }
190
+ when Array
191
+ files.each_index { |index| subject.stub(:test).with( ?M, File.join( PRUNE_PATH, files[index] ) ) { index } }
192
+ Dir.stub( :entries ).with( PRUNE_PATH ) { files }
193
+ when Hash
194
+ files.each_key { |key| subject.stub(:test).with( ?M, File.join( PRUNE_PATH, key ) ) { files[key] } }
195
+ Dir.stub( :entries ).with( PRUNE_PATH ) { files.keys }
196
+ else
197
+ raise "Don't know how to stub files for #{files.class}"
198
+ end
199
+ end
200
+
201
+ def stub_messages
202
+ @messages = []
203
+ $stdout.stub( :write ) { |message| @messages << message }
204
+ end
205
+
206
+ end
@@ -0,0 +1,147 @@
1
+ require 'prune/retention'
2
+ require 'prune/category'
3
+ require 'rspec'
4
+ require 'spec/spec_helper'
5
+
6
+ DAY = 24 * 60 * 60
7
+
8
+ describe Prune::RetentionPolicy do
9
+
10
+ SOURCE_DIR = "source_path"
11
+ SOURCE_FILE = "source_file"
12
+ SOURCE_PATH = "#{SOURCE_DIR}/#{SOURCE_FILE}"
13
+
14
+ subject { Prune::RetentionPolicy.new SOURCE_DIR }
15
+
16
+ describe "default retention policy" do
17
+
18
+ it "should return categories in dsl order" do
19
+ cats = subject.categories
20
+ cats.shift.description.should include( "Ignoring directories" )
21
+ cats.shift.description.should include( "from the Last Two Weeks" )
22
+ cats.shift.description.should include( "Retaining 'Friday'" )
23
+ cats.shift.description.should include( "Removing 'Non-Friday'" )
24
+ cats.shift.description.should include( "Archiving" )
25
+ cats.should be_empty
26
+ end
27
+
28
+ describe "analyzing a directory" do
29
+ let( :dircat ) do
30
+ File.stub( :directory? ).with( SOURCE_PATH ) { true }
31
+ File.stub( :mtime ).with( SOURCE_PATH ) { Time.now }
32
+ subject.categorize( SOURCE_FILE )
33
+ end
34
+
35
+
36
+ it "should be categorized as 'Ignoring directories'" do
37
+ dircat.description.should eq( "Ignoring directories" )
38
+ end
39
+
40
+ it "should invoke action :ignore" do
41
+ dircat.action.should eq( :ignore )
42
+ end
43
+ end
44
+
45
+ describe "analyzing a file" do
46
+
47
+ describe "created yesterday" do
48
+
49
+ let( :yestercat ) do
50
+ File.stub( :directory? ).with( SOURCE_PATH ) { false }
51
+ File.stub( :mtime ).with( SOURCE_PATH ) { Time.now - DAY }
52
+ subject.categorize( SOURCE_FILE )
53
+ end
54
+
55
+ it "should be categorized as '... Last Two Weeks'" do
56
+ yestercat.description.should include( 'Last Two Weeks' )
57
+ end
58
+
59
+ it "should invoke action :retain" do
60
+ yestercat.action.should eq( :retain )
61
+ end
62
+ end
63
+
64
+ describe "created three weeks ago, wednesday" do
65
+
66
+ let( :weeksago ) do
67
+ File.stub( :directory? ).with( SOURCE_PATH ) { false }
68
+ File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 3, 'Wed' ) }
69
+ subject.categorize( SOURCE_FILE )
70
+ end
71
+
72
+ it "should be described as 'Older than Two Weeks' and 'Non-Friday'" do
73
+ weeksago.description.should include 'Non-Friday'
74
+ weeksago.description.should include 'Older than Two Weeks'
75
+ end
76
+
77
+ it "should invoke action :remove" do
78
+ weeksago.action.should eq( :remove )
79
+ end
80
+
81
+ end
82
+
83
+ describe "created three weeks ago, friday" do
84
+
85
+ let( :weeksagofriday ) do
86
+ File.stub( :directory? ).with( SOURCE_PATH ) { false }
87
+ File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 3, 'Fri' ) }
88
+ subject.categorize( SOURCE_FILE )
89
+ end
90
+
91
+ it "should be described as 'Friday files', 'Older than Two Weeks'" do
92
+ weeksagofriday.description.should include( "'Friday' files" )
93
+ weeksagofriday.description.should include( 'Older than Two Weeks' )
94
+ end
95
+
96
+ it "should invoke action :remove" do
97
+ weeksagofriday.action.should eq( :retain )
98
+ end
99
+
100
+ end
101
+
102
+ describe "created three months ago, friday" do
103
+
104
+ let( :oldfriday ) do
105
+ File.stub( :directory? ).with( SOURCE_PATH ) { false }
106
+ File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 12, 'Fri' ) }
107
+ subject.categorize( SOURCE_FILE )
108
+ end
109
+
110
+ it "should be described as 'Older than Two Months'" do
111
+ oldfriday.description.should include( 'Older than Two Months' )
112
+ end
113
+
114
+ it "should invoke action :archive" do
115
+ oldfriday.action.should eq( :archive )
116
+ end
117
+
118
+ end
119
+
120
+ describe "created three months ago, wednesday" do
121
+
122
+ let( :oldwednesday ) do
123
+ File.stub( :directory? ).with( SOURCE_PATH ) { false }
124
+ File.stub( :mtime ).with( SOURCE_PATH ) { weeks_ago( 12, 'Wed' ) }
125
+ subject.categorize( SOURCE_FILE )
126
+ end
127
+
128
+ it "should be described as 'Non-Friday files', 'Older than Two Weeks'" do
129
+ oldwednesday.description.should include( "'Non-Friday' files" )
130
+ oldwednesday.description.should include( "Older than Two Weeks" )
131
+ end
132
+
133
+ it "should invoke action :remove" do
134
+ oldwednesday.action.should eq( :remove )
135
+ end
136
+
137
+ end
138
+ end
139
+ end
140
+
141
+ end
142
+
143
+ def weeks_ago( weeks, weekday )
144
+ sub_weeks = Time.now - ( DAY * 7 * weeks )
145
+ weekday_adjustment = Time.now.wday - Date::ABBR_DAYNAMES.index( weekday )
146
+ sub_weeks - ( weekday_adjustment * DAY )
147
+ end
@@ -0,0 +1,5 @@
1
+ RSpec::Matchers.define :include_match do |expected|
2
+ match do |actual|
3
+ !actual.grep( expected ).empty?
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: geoffreywiseman-prune
3
+ version: !ruby/object:Gem::Version
4
+ hash: 19
5
+ prerelease:
6
+ segments:
7
+ - 1
8
+ - 1
9
+ - 0
10
+ version: 1.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Geoffrey Wiseman
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-09-09 00:00:00 Z
19
+ dependencies: []
20
+
21
+ description: Prune is meant to analyze a folder full of files, run them against a retention policy and decide which to keep, which to remove and which to archive. It is extensible and embeddable.
22
+ email: geoffrey.wiseman@codiform.com
23
+ executables:
24
+ - prune
25
+ extensions: []
26
+
27
+ extra_rdoc_files: []
28
+
29
+ files:
30
+ - lib/prune/archiver.rb
31
+ - lib/prune/category.rb
32
+ - lib/prune/cli.rb
33
+ - lib/prune/default_retention.rb
34
+ - lib/prune/grouper.rb
35
+ - lib/prune/pruner.rb
36
+ - lib/prune/retention.rb
37
+ - lib/prune.rb
38
+ - spec/archiver_spec.rb
39
+ - spec/cli_spec.rb
40
+ - spec/grouper_spec.rb
41
+ - spec/pruner_spec.rb
42
+ - spec/retention_spec.rb
43
+ - spec/spec_helper.rb
44
+ - bin/prune
45
+ - Rakefile
46
+ - README.mdown
47
+ - UNLICENSE
48
+ homepage: http://geoffreywiseman.github.com/prune
49
+ licenses: []
50
+
51
+ post_install_message:
52
+ rdoc_options: []
53
+
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ hash: 3
62
+ segments:
63
+ - 0
64
+ version: "0"
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ hash: 3
71
+ segments:
72
+ - 0
73
+ version: "0"
74
+ requirements: []
75
+
76
+ rubyforge_project:
77
+ rubygems_version: 1.8.10
78
+ signing_key:
79
+ specification_version: 3
80
+ summary: Prunes files from a folder based on a retention policy, often time-based.
81
+ test_files: []
82
+