geoffreywiseman-prune 1.1.0

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