#!/usr/bin/env perl # Copyright (c) 2025 Daniel Wilkins # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR #ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # By the way you're an idiot if you use this for anything you care about. use v5.30; use strict; use diagnostics; use autodie; use Cwd qw(abs_path); use File::Find; use Array::Utils qw(unique array_minus); use List::Util qw(max); use List::MoreUtils qw(zip); use File::Copy::Recursive qw(dircopy fcopy); use File::Slurp; # Utility functions sub findRoot { my $dir = abs_path("."); while (!(-d "$dir/.tvc") and !($dir eq "/")) { $dir = abs_path("$dir/.."); } if (-d "$dir/.tvc") { return "$dir"; } return undef; } sub findDb { my $root = findRoot(); if (defined($root)) { return "$root/.tvc"; } return undef; } sub latest { my $commits = shift; return ((max(map({substr($_, length($commits) + 1)} glob("$commits/*"))) || 0)); } # Load the tracked file, but remove any files which have since been deleted. # To give us a view of the currently tracked files sub loadTracked { my $root = shift(); my $db = "$root/.tvc"; my @tracked = read_file("$db/tracked", {chomp => 1}); return grep(-f, map({substr(abs_path($_), length($root) + 1)} @tracked)); } sub touch { my ($file) = @_; open(my $fh, '>>', $file); close($fh); } sub usage { say("tvc [command]"); say("where command is one of the following:"); say("clean: delete all untracked files in the workspace."); say("checkout [commit]: replace the files in the worktree with the ones from commit"); say("diff [commit1] [commit2]: output the difference from commit1 to commit2. uses the workspace if commit2 is omitted. uses the latest commit if commit1 is omitted"); say("get [remote] [localname]: creates a copy of the repository at remote named localname"); say("init: creates a new empty tvc repository"); say("log: lists all commits and their messages in order"); say("pull: rsyncs the remote's .tvc directory to the current repository"); say("push: rsyncs the current repository's .tvc directory to the remote"); say("remote [remote]: sets the repository's remote to remote"); say("track [files]: adds files to the repository's tracked list"); say("which: prints the root of the current repository"); } # Commands sub checkout { my $commit = (@{shift()})[0]; if (! $commit) { say("Commit is required"); return; } my $root = findRoot(); my $db = "$root/.tvc"; my $tree = "$db/commits/$commit/tree"; if (not -d $tree) { say("Commit $commit does not exist"); return; } dircopy($tree, $root); my @newtrack = (); find(sub {push(@newtrack, substr($File::Find::name, length($tree) + 1)) if not -d $File::Find::name; }, $tree); write_file("$db/tracked", join("\n", @newtrack)); } sub clean { my $root = findRoot(); my @tracked = loadTracked($root); my @files = (); find(sub { if ($File::Find::name eq $root) { return; } my $relname = substr($File::Find::name, length($root) + 1); CORE::push(@files, $relname) if not -d $File::Find::name and (length($relname) < 4 or not (substr($relname, 0, 4) eq ".tvc")); }, $root); my @toDelete = array_minus(@files, @tracked); unlink @toDelete; } sub commit { my $message = join(" ", @{shift()}); if (not $message) { say("Commit message required"); return; } my $root = findRoot(); my $db = "$root/.tvc"; my $commits = "$db/commits"; my $cid = latest($commits) + 1; my $next = "$commits/" . $cid; mkdir($next); write_file("$next/msg", $message); mkdir("$next/tree"); my @files = loadTracked($root); foreach my $file (@files) { fcopy("$root/$file", "$next/tree/$file"); } say($cid); } sub diff { my $root = findRoot(); my @args = @{shift()}; my $from = undef; my $to = "$root/"; my @tracked = loadTracked($root); if (not @args) { $from = "$root/.tvc/commits/" . latest("$root/.tvc/commits") . "/tree/"; } elsif (scalar(@args) == 1) { my $arg = $args[0]; $from = "$root/.tvc/commits/$arg/tree/"; } else { my $arg1 = $args[0]; my $arg2 = $args[1]; $from = "$root/.tvc/commits/$arg1/tree/"; $to = "$root/.tvc/commits/$arg2/tree/"; } my @froms = map({$from . $_} @tracked); my @tos = map({$to . $_} @tracked); for(my $i = 0; $i <= $#tos; $i++) { system("diff -uN $froms[$i] $tos[$i]"); } } sub get { my @args = @{shift()}; my $remote = $args[0]; my $local = $args[1]; if (! $remote) { say("Remote is required"); return; } if (! $local) { say("Local is required"); return; } `rsync -avz $remote/.tvc $local`; chdir($local); my $db = findDb(); my $latest = latest("$db/commits"); checkout([$latest]); } sub init { mkdir(".tvc"); mkdir(".tvc/commits"); touch(".tvc/tracked"); } sub log { my $db = findDb(); my @lines = (); find(sub { if (length($File::Find::name) <= length("$db/commits/")) { # Can't possibly be a commit message. return; } my $name = substr($File::Find::name, length("$db/commits/")); if ($name =~ /^\d+\/msg$/) { my $commit = substr($File::Find::dir, length("$db/commits/")); my $msg = read_file($File::Find::name); push(@lines, "$commit\t$msg"); } }, "$db/commits"); foreach my $line (sort(@lines)) { say($line); } } sub remote { my $remote = (@{shift()})[0]; if (! $remote) { say("Remote is required"); return; } write_file(findDb() . "/remote", $remote); } sub pull { my $root = findRoot(); my $db = "$root/.tvc"; my $remote = read_file("$db/remote", {chomp => 1}); if (not $remote) { say("No remote set"); return; } `rsync -avz $remote/.tvc $root`; } sub push { my $db = findDb(); my $remote = read_file("$db/remote", {chomp => 1}); if (not $remote) { say("No remote set"); return; } `rsync -avz .tvc $remote`; } sub track { my $root = findRoot(); my $db = "$root/.tvc"; my @files = grep({-f $_ and not -d $_} map({substr(abs_path($_), length($root) + 1)} @{shift()})); if (not @files) { say("No files to track"); return; } my @tracked = loadTracked($root); @files = unique(@files, @tracked); write_file("$db/tracked", join("\n", @files)); # File::Slurp lies and does not in fact take a list of lines, it will join them with "" } sub which { my $db = findRoot(); if (defined($db)) { say($db); } else { say("Not in a tvc repo"); } } my %commands = (clean => \&clean, checkout => \&checkout, commit => \&commit, diff => \&diff, get => \&get, init => \&init, log => \&log, pull => \&pull, push => \&push, remote => \&remote, track => \&track, which => \&which); my $cmd = shift(@ARGV); my $command = $commands{$cmd}; if ($command) { &{$command}(\@ARGV); } else { usage(); }