File Coverage

blib/lib/App/tmclean.pm
Criterion Covered Total %
statement 26 104 25.0
branch 0 30 0.0
condition 0 14 0.0
subroutine 9 21 42.8
pod 0 10 0.0
total 35 179 19.5


line stmt bran cond sub pod time code
1             package App::tmclean;
2 1     1   636 use 5.010;
  1         3  
3 1     1   4 use warnings;
  1         2  
  1         25  
4              
5 1     1   419 use version 0.77; our $VERSION = version->declare("v0.0.4");
  1         1537  
  1         6  
6              
7 1     1   667 use Getopt::Long qw/GetOptions :config posix_default no_ignore_case bundling auto_help/;
  1         8368  
  1         4  
8 1     1   678 use Pod::Usage qw/pod2usage/;
  1         40308  
  1         89  
9             use Class::Accessor::Lite (
10 1         7 new => 1,
11             ro => [qw/before days dry_run/],
12 1     1   548 );
  1         988  
13 1     1   495 use HTTP::Date qw/str2time/;
  1         3799  
  1         47  
14 1     1   435 use Time::Piece ();
  1         5714  
  1         23  
15 1     1   7 use Time::Seconds ();
  1         2  
  1         1018  
16              
17             sub logf {
18 0     0 0   my $msg = shift;
19 0           $msg = sprintf($msg, @_);
20 0           my $prefix = '[tmclean]' . Time::Piece->localtime->strftime('[%Y-%m-%d %H:%M:%S] ');
21 0 0         $msg .= "\n" if $msg !~ /\n$/;
22 0           print STDERR $prefix . $msg;
23             }
24              
25             sub new_with_options {
26 0     0 0   my ($class, @argv) = @_;
27              
28 0           my ($opt) = $class->parse_options(@argv);
29 0           $class->new($opt);
30             }
31              
32             sub parse_options {
33 0     0 0   my ($class, @argv) = @_;
34              
35 0           local @ARGV = @argv;
36 0 0         GetOptions(\my %opt, qw/
37             days=i
38             before=s
39             dry-run
40             /) or pod2usage(2);
41              
42 0           $opt{dry_run} = delete $opt{'dry-run'};
43 0           return (\%opt, \@ARGV);
44             }
45              
46             sub run {
47 0     0 0   my $self = shift;
48              
49 0 0 0       if (!$self->dry_run && $ENV{USER} ne 'root') {
50 0           die "tmutil requires root privileges\n";
51             }
52 0           $self->cmd(qw/tmutil stopbackup/);
53 0           $self->cmd(qw/tmutil disable/); # need sudo
54              
55 0           my @targets = $self->backups2delete;
56 0 0         unless (@targets) {
57 0           logf 'no deletion targets found';
58 0           return 0;
59             }
60 0           my $mount_point = $self->mount_point;
61              
62 0           logf "following backups to be deleted:\n %s", join("\n ", @targets);
63 0           for my $bak (@targets) {
64 0           $self->cmd(qw/tmutil delete/, $bak); # need sudo
65             }
66 0           my $dev_name = dev_name($targets[0]);
67 0           $self->cmd(qw/hdiutil detach/, $dev_name);
68              
69 0           my $backupbundle_path = sprintf '%s/%s.sparsebundle', $mount_point, $self->machine_name;
70 0 0         if (! -d $backupbundle_path) {
71             # backupbundle path is changed after Catalina
72 0           $backupbundle_path =~ s/\.sparsebundle$/.backupbundle/;
73             }
74 0           $self->cmd(qw/hdiutil compact/, $backupbundle_path); # need sudo
75 0           $self->cmd(qw/tmutil enable/); # need sudo
76             }
77              
78             sub backups2delete {
79 0     0 0   my $self = shift;
80 0           my @backups = `tmutil listbackups`;
81 0 0         if ($? != 0) {
82 0           die "failed to execute `tmutil listbackups`: $?\n";
83             }
84             # e.g. /Volumes/Time Machine Backup/Backups.backupdb/$machine/2018-01-07-033608
85             return grep {
86 0           chomp;
  0            
87 0           my @paths = split m!/!, $_;
88 0           my $backup_date = eval { Time::Piece->strptime($paths[-1], '%Y-%m-%d-%H%M%S') };
  0            
89 0 0         $backup_date && $self->before_tp > $backup_date;
90             } @backups;
91             }
92              
93             sub mount_point {
94 0     0 0   my $self = shift;
95              
96             $self->{mount_point} ||= sub {
97 0     0     my @lines = `tmutil destinationinfo`;
98 0 0         if ($? != 0) {
99 0           die "failed to execute `tmutil destinationinfo`: $?\n";
100             }
101 0           for my $line (@lines) {
102 0           chomp $line;
103 0           my ($key, $val) = split /\s+:\s+/, $line, 2;
104 0 0         if ($key eq 'Mount Point') {
105 0           return $val;
106             }
107             }
108 0           die "no mount points found\n";
109 0   0       }->();
110             }
111              
112             sub dev_name {
113 0     0 0   my $path = shift;
114 0           my @paths = split m!/!, $path;
115 0           join '/', @paths[0..2];
116             }
117              
118             sub machine_name {
119 0     0 0   my $self = shift;
120              
121 0   0       $self->{machine_name} ||= do {
122 0           chomp(my $hostname = `hostname`);
123 0 0         if ($? != 0) {
124 0           die "failed to execute `hostname`: $?\n";
125             }
126 0           $hostname =~ s/\.local$//;
127 0           $hostname;
128             };
129             }
130              
131             sub before_tp {
132 0     0 0   my $self = shift;
133              
134             $self->{before_tp} ||= sub {
135 0 0   0     if ($self->before) {
136 0           my $time = str2time $self->before; # str2time parses the time as localtime
137 0 0         die "unrecognized date format @{[$self->before]}" unless $time;
  0            
138 0           return Time::Piece->localtime($time);
139             }
140 0   0       my $days = $self->days || 366;
141 0           return Time::Piece->localtime() - Time::Seconds::ONE_DAY() * $days;
142 0   0       }->();
143             }
144              
145             sub cmd {
146 0     0 0   my ($self, @command) = @_;
147              
148 0           my $cmd_str = join(' ', @command);
149 0 0         logf 'execute%s: `%s`', $self->dry_run ? '(dry-run)' : '', $cmd_str;
150 0 0         if (!$self->dry_run) {
151 0 0         !system(@command) or die "failed to execute command: $cmd_str: $?\n";
152             }
153             }
154              
155             1;
156             __END__