summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSadeep Madurange <sadeep@asciimx.com>2026-03-27 21:20:07 +0800
committerSadeep Madurange <sadeep@asciimx.com>2026-03-29 19:05:52 +0800
commit42e68ccc9267907500f3e7d22bb7a31c790ef394 (patch)
tree0c21df63ba8e95a9fac5c6803d23d043a5e701d2
parent3b9af22464af7f29110a5e0ff86c7b1f43fa6dad (diff)
downloadcvn-42e68ccc9267907500f3e7d22bb7a31c790ef394.tar.gz
Add, commit, log commands.
-rw-r--r--vcx485
1 files changed, 397 insertions, 88 deletions
diff --git a/vcx b/vcx
index 3d7f6f4..a3d4353 100644
--- a/vcx
+++ b/vcx
@@ -9,169 +9,478 @@ use File::Basename;
use File::Glob qw(:bsd_glob);
use File::Spec;
use Digest::SHA qw(sha1_hex);
+use POSIX qw(strftime);
-use constant VCX_DIR => '.vcx';
-use constant BSE_DIR => VCX_DIR . '/bse';
-use constant OBJ_DIR => VCX_DIR . '/obj';
-use constant TMP_DIR => VCX_DIR . '/tmp';
-use constant IGNORE_FILE => '.vcxignore';
+use constant VCX_DIR => '.vcx';
+use constant OBJ_DIR => VCX_DIR . '/store'; # Latest version of a file
+use constant REV_DIR => VCX_DIR . '/revs'; # Commits
+use constant TREE_DIR => VCX_DIR . '/trees'; # Trees
+use constant DIFF_DIR => VCX_DIR . '/deltas'; # Reverse deltas
+use constant HEAD_FILE => VCX_DIR . '/head'; # Current commit ID
+
+# Staging area
+use constant TMP_DIR => VCX_DIR . '/index';
+use constant TMP_TREE => TMP_DIR . '/tree';
+use constant TMP_DIFF => TMP_DIR . '/deltas';
+use constant TMP_META_FILE => VCX_DIR . '/meta';
my $cmd = shift @ARGV // '';
-my @paths = @ARGV;
+my @args = @ARGV;
if ($cmd eq 'init') {
- init_repo();
+ run_init();
} elsif ($cmd eq 'status') {
run_status();
} elsif ($cmd eq 'add') {
- die "Usage: $0 add [path1] [path2] ...\n" unless @paths;
- run_add(@paths);
+ die "Usage: $0 add [path1] [path2] ...\n" unless @args;
+ run_add(@args);
+} elsif ($cmd eq 'commit') {
+ # If the user typed: vcx commit "My message"
+ # $args[0] will contain the message. If not, it's empty.
+ my $message = join(' ', @args);
+ run_commit($message);
+} elsif ($cmd eq 'log') {
+ run_log();
} else {
- print "Usage: $0 [init|status|add]\n";
+ print "Usage: $0 [init|status|add|commit|log]\n";
exit 1;
}
-sub init_repo {
- make_path(OBJ_DIR, BSE_DIR, TMP_DIR);
- print "Repository ready\n";
-}
+sub run_init {
+ make_path(OBJ_DIR, REV_DIR, DIFF_DIR);
-sub run_status {
- my $compare_file = sub {
- return if $File::Find::name =~ /^\.\/\Q${\VCX_DIR}\E/;
- return if -d $File::Find::name;
+ my $initial_hex = to_hex_id(0);
+ my $rev0_dir = File::Spec->catfile(REV_DIR, $initial_hex);
+ make_path($rev0_dir);
- my $path = File::Spec->abs2rel($File::Find::name, '.');
- $path =~ s|^\./||;
- my $base_path = File::Spec->catfile(BSE_DIR, $path);
+ open my $fh, '>', HEAD_FILE or die $!;
+ print $fh "$initial_hex\n";
+ close $fh;
- if (-e $base_path) {
- if (system("diff -q '$File::Find::name' '$base_path' > /dev/null") != 0) {
- print "[M] $path" . (check_staged_status($path, 'M') ? " (staged)" : "") . "\n";
- }
- } else {
- print "[N] $path" . (check_staged_status($path, 'N') ? " (staged)" : "") . "\n";
- }
- };
+ # Baseline tree (empty) and message
+ open my $tfh, '>', File::Spec->catfile($rev0_dir, "tree"); close $tfh;
+ open my $mfh, '>', File::Spec->catfile($rev0_dir, "message");
+ close $mfh;
+}
+
+sub run_status {
+ open my $fh, '<', HEAD_FILE or die "VCX not initialized.\n";
+ my $head = <$fh>; chomp $head; close $fh;
- find({ wanted => $compare_file, no_chdir => 1 }, '.');
+ my $latest_tree_dir = File::Spec->catfile(REV_DIR, $head, "tree");
+ print "On revision [$head]\n";
+ # Pass 1: Workspace -> History (Detects New and Modified)
find({
wanted => sub {
- return if -d $_;
- my $rel = File::Spec->abs2rel($_, BSE_DIR);
- if (!-e $rel) {
- print "[D] $rel" . (check_staged_status($rel, 'D') ? " (staged)" : "") . "\n";
+ return if $File::Find::name =~ /^\.\/\Q${\VCX_DIR}\E/;
+ return if -d $File::Find::name;
+
+ my $rel = File::Spec->abs2rel($File::Find::name, '.');
+ $rel =~ s|^\./||;
+
+ my $base_in_tree = File::Spec->catfile($latest_tree_dir, $rel);
+
+ if (-e $base_in_tree || -l $base_in_tree) {
+ my $obj_in_store = readlink($base_in_tree);
+ if (!compare_files($File::Find::name, $obj_in_store)) {
+ my $staged = check_staged_status($rel, 'M') ? " (staged)" : "";
+ print "[M] $rel$staged\n";
+ }
+ } else {
+ my $staged = check_staged_status($rel, 'N') ? " (staged)" : "";
+ print "[N] $rel$staged\n";
}
},
no_chdir => 1
- }, BSE_DIR);
+ }, '.');
+
+ # Pass 2: History -> Workspace (Detects Deletions)
+ if (-d $latest_tree_dir) {
+ find({
+ wanted => sub {
+ return if -d $_;
+ my $rel = File::Spec->abs2rel($_, $latest_tree_dir);
+
+ # If it's in the commit tree but GONE from the workspace
+ if (!-e $rel && !-l $rel) {
+ my $staged = check_staged_status($rel, 'D') ? " (staged)" : "";
+ print "[D] $rel$staged\n";
+ }
+ },
+ no_chdir => 1
+ }, $latest_tree_dir);
+ }
}
sub check_staged_status {
my ($path, $type) = @_;
- my $tmp_link = File::Spec->catfile(TMP_DIR, $path);
- return 0 unless -l $tmp_link;
+ my $path_hash = sha1_hex($path);
+ my $tmp_link = File::Spec->catfile(TMP_TREE, $path);
- my $staged_target = readlink($tmp_link);
-
- if (-f $path) {
- # The staged target (e.g., ../obj/path.tmp) must exist to diff
- my $abs_target = File::Spec->rel2abs($staged_target, dirname($tmp_link));
- return 0 unless -e $abs_target;
- return (system("diff -q '$path' '$abs_target' > /dev/null") == 0);
+ # If the file is in history but the symlink not in TMP_DIR, it's staged for deletion.
+ if ($type eq 'D') {
+ return !-e $tmp_link && !-l $tmp_link;
}
-
- if (-l $path) {
- # Compare where the work tree link points vs where the staging link points
- return (readlink($path) eq $staged_target);
+
+ # If it's a new file, it's staged if the symlink exists in TMP_DIR
+ if ($type eq 'N') {
+ return (-e $tmp_link || -l $tmp_link);
}
-
+
+ if ($type eq 'M') {
+ # Check if a patch exists in the temporary diff directory.
+ # We look for any file starting with the path_hash in TMP_DIFF.
+ my $patch_pattern = File::Spec->catfile(TMP_DIFF, "$path_hash.*.patch");
+ my @patches = bsd_glob($patch_pattern);
+ return scalar @patches > 0;
+ }
+
return 0;
}
sub run_add {
- my (@targets) = @_;
+ my @targets = @_;
+ make_path(TMP_TREE, TMP_DIFF);
- foreach my $target (@targets) {
- # Expand globs (e.g., *.txt) for each target
- my @expanded = ($target eq '.') ? ('.') : bsd_glob($target);
+ open my $fh_h, '<', HEAD_FILE or die $!;
+ my $head = <$fh_h>; chomp $head; close $fh_h;
+ my $latest_tree_dir = File::Spec->catfile(REV_DIR, $head, "tree");
+ my $next_id_hex = to_hex_id(from_hex_id($head) + 1);
+ foreach my $input (@targets) {
+ my @expanded = bsd_glob($input);
foreach my $t (@expanded) {
- next unless -e $t; # Skip if file doesn't exist
find({
wanted => sub {
return if $File::Find::name =~ /^\.\/\Q${\VCX_DIR}\E/;
- my $rel = File::Spec->abs2rel($File::Find::name, '.');
- $rel =~ s|^\./||;
-
- my $tmp_link = File::Spec->catfile(TMP_DIR, $rel);
-
+ return if -d $File::Find::name;
+
+ my $rel = $File::Find::name =~ s|^\./||r;
+ my $staged_path = File::Spec->catfile(TMP_TREE, $rel);
+
+ # CASE 1: Regular File
if (-f $File::Find::name && !-l $File::Find::name) {
- _sync_file_to_obj($File::Find::name, undef, $tmp_link);
+ return if -e $staged_path; # Already staged
+
+ if (-l File::Spec->catfile($latest_tree_dir, $rel)) { # No changes
+ my $obj_in_head = readlink(File::Spec->catfile($latest_tree_dir, $rel));
+ return if compare_files($File::Find::name, $obj_in_head);
+ }
+
+ # Generate deltas if modified
+ my $obj_name = sha1_hex($rel);
+ my $obj_path = File::Spec->catfile(OBJ_DIR, $obj_name);
+
+ if (-e $obj_path) {
+ my $p_path = File::Spec->catfile(TMP_DIFF, "$obj_name.$next_id_hex.patch");
+ if (-T $_) {
+ system("diff -u '$obj_path' '$_' > '$p_path'")
+ if system("diff -q '$obj_path' '$_' > /dev/null") != 0;
+ } else {
+ make_bin_patch($_, $obj_path, $p_path);
+ }
+ }
+
+ stage_file($File::Find::name, $obj_path, $staged_path);
+
+ # Record staged file in meta for commit command to find it
+ open my $afh, '>>', TMP_META_FILE or die $!;
+ print $afh "$rel\n";
+ close $afh;
}
+
+ # CASE 2: Symlink
elsif (-l $File::Find::name) {
- _sync_symlink_to_tmp($File::Find::name, $tmp_link);
+ my $target = readlink($File::Find::name);
+
+ # HEAD link points to the same target
+ if (-l File::Spec->catfile($latest_tree_dir, $rel)) {
+ return if readlink(File::Spec->catfile($latest_tree_dir, $rel)) eq $target;
+ }
+
+ # Staged link points to the same target
+ if (-l $staged_path) {
+ return if readlink($staged_path) eq $target;
+ }
+
+ stage_link($File::Find::name, $staged_path);
}
},
no_chdir => 1,
}, $t);
-
- _handle_deletions($t);
}
}
}
-# For Regular Files: Copy to OBJ and link to TMP
-sub _sync_file_to_obj {
- my ($src, $obj_path_not_used, $tmp) = @_;
+sub run_commit {
+ my ($message) = @_;
+
+ if (! -e TMP_META_FILE) {
+ print "Nothing to commit.";
+ return;
+ }
+
+ if (!$message || $message eq "") {
+ $message = launch_editor();
+ die "Commit aborted: empty message.\n" unless $message =~ /\S/;
+ }
+
+ # Prepare IDs
+ open my $fh_h, '<', HEAD_FILE or die "Not a repository.\n";
+ my $old_head = <$fh_h>; chomp $old_head; close $fh_h;
+ my $next_id_hex = to_hex_id(from_hex_id($old_head) + 1);
+ my $rev_dir = File::Spec->catfile(REV_DIR, $next_id_hex);
+ make_path($rev_dir);
+
+ # Save file to store
+ if (-e TMP_META_FILE) {
+ open my $mfh, '<', TMP_META_FILE or die $!;
+ while (my $rel = <$mfh>) {
+ chomp $rel;
+ my $obj_name = sha1_hex($rel);
+ my $obj_path = File::Spec->catfile(OBJ_DIR, $obj_name);
+
+ # Copy workspace version to the permanent store
+ copy($rel, $obj_path) or die "Failed to update store for $rel: $!";
+ }
+ close $mfh;
+ # Delete meta so it doesn't leak into the next commit
+ unlink TMP_META_FILE;
+ }
+
+ # Save the tree (snapshots the structure)
+ rename(TMP_TREE, File::Spec->catfile($rev_dir, "tree"))
+ or die "Failed to move staged tree: $!";
+
+ # Move deltas
+ if (-d TMP_DIFF) {
+ my $dest_diff_dir = File::Spec->catfile($rev_dir, "deltas");
+ rename(TMP_DIFF, $dest_diff_dir)
+ or die "Failed to move deltas to $dest_diff_dir: $!";
+ }
+
+ # Commit message
+ open my $msg_fh, '>', File::Spec->catfile($rev_dir, "message") or die $!;
+ print $msg_fh "$message\n";
+ close $msg_fh;
+
+ # Update head
+ open my $head_fh, '>', HEAD_FILE or die $!;
+ print $head_fh "$next_id_hex\n";
+ close $head_fh;
+
+ # Clean up staging area
+ File::Path::remove_tree(TMP_DIR) if -d TMP_DIR;
- my $rel_path = File::Spec->abs2rel($src, '.');
- $rel_path =~ s|^\./||;
+ my ($subject) = split(/\n/, $message);
+ print "Committed revision [$next_id_hex]: $subject\n";
+}
- my $filename = sha1_hex($rel_path) . ".tmp";
- my $obj = File::Spec->catfile(OBJ_DIR, $filename);
+sub run_log {
+ open my $fh_h, '<', HEAD_FILE or die "Not a repository.\n";
+ my $head = <$fh_h>; chomp $head; close $fh_h;
- make_path(dirname($tmp));
- copy($src, $obj) or die "Copy failed: $!";
+ # Setup pager
+ my $pager = $ENV{PAGER} || 'less -R';
+ open(my $pipe, "| $pager") or die "Can't pipe to $pager: $!";
+ my $old_fh = select($pipe);
- my $target = File::Spec->abs2rel($obj, dirname($tmp));
+ print "Revision History (HEAD: $head)\n\n";
+
+ my $rev_num = from_hex_id($head);
+ while ($rev_num > 0) {
+ my $hex_id = to_hex_id($rev_num);
+ my $rev_dir = File::Spec->catfile(REV_DIR, $hex_id);
+
+ # Stop if we hit a gap in history or the beginning
+ last unless -d $rev_dir;
+
+ # Stat index 9 is the last modification time
+ my $mtime = (stat($rev_dir))[9];
+ my $date_str = strftime("%a %b %e %H:%M:%S %Y", localtime($mtime));
+
+ my $msg_file = File::Spec->catfile($rev_dir, "message");
+ my $message = "[No message]";
+
+ if (-e $msg_file) {
+ open my $mfh, '<', $msg_file;
+ $message = do { local $/; <$mfh> } // "[Empty message]";
+ $message =~ s/^\s+|\s+$//g; # Trim whitespace
+ close $mfh;
+ }
+
+ print "commit $hex_id\n";
+ print "Date: $date_str\n";
+ print "\n $message\n\n";
+
+ $rev_num--;
+ }
+
+ close $pipe;
+ select($old_fh);
+}
+
+sub stage_file {
+ my ($src, $obj, $tmp) = @_;
+ make_path(dirname($tmp));
+
+ # We want the link inside tmp/ to point to "obj/HASH"
+ # relative to project root.
+ my $rel_target = $obj;
unlink($tmp) if -e $tmp || -l $tmp;
- symlink($target, $tmp) or die "Symlink failed: $!";
+ symlink($rel_target, $tmp) or die "Failed to symlink $tmp: $!";
+ print "[Staged File] $src\n";
}
-# For Symlinks: Mirror the symlink into TMP
-sub _sync_symlink_to_tmp {
+sub stage_link {
my ($src, $tmp) = @_;
- my $target = readlink($src); # Read where the original points
+ my $target = readlink($src);
make_path(dirname($tmp));
unlink($tmp) if -e $tmp || -l $tmp;
- symlink($target, $tmp); # Create exact same link in TMP
- print "[Add Link] $src -> $target\n";
+ # For workspace symlinks, we clone the target
+ symlink($target, $tmp) or die "Failed to symlink $tmp: $!";
+ print "[Staged link] $src -> $target\n";
}
-sub _handle_deletions {
- my ($target) = @_;
-
- # If target is '.', search the whole BSE_DIR. Otherwise, search the specific path in BSE_DIR.
- my $search = ($target eq '.') ? BSE_DIR : File::Spec->catfile(BSE_DIR, $target);
-
+sub stage_deletions {
+ my ($target) = @_;
+
+ open my $fh, '<', HEAD_FILE or die "Not a repository.\n";
+ my $head = <$fh>; chomp $head; close $fh;
+ my $latest_tree = File::Spec->catfile(REV_DIR, $head, "tree");
+
+ my $search = ($target eq '.') ? $latest_tree : File::Spec->catfile($latest_tree, $target);
return unless -d $search || -e $search;
find({
wanted => sub {
return if -d $_; # Skip directories
-
- my $rel = File::Spec->abs2rel($_, BSE_DIR);
+
+ # '$_' is the file inside the commit tree
+ # '$rel' is its path relative to the commit root
+ my $rel = File::Spec->abs2rel($_, $latest_tree);
+
+ # File exists in history but is gone from the workspace
if (!-e $rel) {
+ # If we have a staged link/file, remove it.
my $tmp_link = File::Spec->catfile(TMP_DIR, $rel);
if (-l $tmp_link || -e $tmp_link) {
unlink($tmp_link);
- }
+ print "Staged deletion: $rel\n";
+ }
}
},
no_chdir => 1
}, $search);
}
+
+sub make_bin_patch {
+ my ($new_file, $old_file, $patch_out) = @_;
+
+ open my $f_new, '<:raw', $new_file or die "Cannot open file: $!";
+ open my $f_old, '<:raw', $old_file or die "Cannot open object file: $!";
+ my $f_out;
+
+ my $offset = 0;
+ my $blk_size = 4096;
+
+ while (1) {
+ my $read_new = sysread($f_new, my $buf_new, $blk_size);
+ my $read_old = sysread($f_old, my $buf_old, $blk_size);
+
+ # Stop when we've processed the entire new file
+ last if $read_new == 0 && $read_old == 0;
+
+ # Handle file size differences (padding with nulls if one is shorter)
+ $buf_new .= "\0" x ($blk_size - length($buf_new)) if length($buf_new) < $blk_size;
+ $buf_old .= "\0" x ($blk_size - length($buf_old)) if length($buf_old) < $blk_size;
+
+ # If they differ, we save the OLD buffer (reverse delta)
+ if ($buf_new ne $buf_old) {
+ if (!$f_out) {
+ make_path(dirname($patch_out));
+ open $f_out, '>:raw', $patch_out or die "Cannot create patch: $!";
+ }
+ # Header: [64-bit offset][32-bit length][data]
+ my $header = pack("QL", $offset, length($buf_old));
+ syswrite($f_out, $header . $buf_old);
+ }
+
+ $offset += $blk_size;
+ }
+ close $f_new; close $f_old;
+ close $f_out if $f_out;
+}
+
+sub apply_bin_patch {
+ my ($obj_file, $patch_file) = @_;
+
+ open my $obj_fh, '+<:raw', $obj_file or die "Cannot open object: $!";
+ open my $ptch_fh, '<:raw', $patch_file or die "Cannot open patch: $!";
+
+ # Header is 12 bytes (Q + L)
+ while (sysread($ptch_fh, my $header, 12)) {
+ my ($offset, $len) = unpack("QL", $header);
+
+ # sysread/syswrite to avoid read()'s internal buffers.
+ sysread($ptch_fh, my $payload, $len);
+ sysseek($obj_fh, $offset, 0);
+ syswrite($obj_fh, $payload, $len);
+ }
+
+ close $obj_fh;
+ close $ptch_fh;
+}
+
+sub compare_files {
+ my ($file1, $file2) = @_;
+ return 0 unless -s $file1 == -s $file2;
+
+ open my $fh1, '<:raw', $file1 or return 0;
+ open my $fh2, '<:raw', $file2 or return 0;
+
+ my $blk_size = 4096;
+ while (1) {
+ my $read1 = sysread($fh1, my $buf1, $blk_size);
+ my $read2 = sysread($fh2, my $buf2, $blk_size);
+ return 0 if $buf1 ne $buf2;
+ last if $read1 == 0;
+ }
+ return 1;
+}
+
+# Convert decimal to a padded 7-char hex string
+sub to_hex_id { sprintf("%07x", $_[0]) }
+
+# Convert hex back to decimal
+sub from_hex_id { hex($_[0]) }
+
+sub launch_editor {
+ my $editor = $ENV{EDITOR} || $ENV{VISUAL} || 'vi';
+ my $temp_msg_file = File::Spec->catfile(VCX_DIR, "COMMIT_EDITMSG");
+
+ open my $fh, '>', $temp_msg_file or die "Cannot create temp message file: $!";
+ print $fh "\n# Enter the commit message for your changes.\n";
+ print $fh "# Lines starting with '#' will be ignored.\n";
+ close $fh;
+
+ system("$editor \"$temp_msg_file\"");
+
+ my $final_msg = "";
+ if (-e $temp_msg_file) {
+ open my $rfh, '<', $temp_msg_file or die $!;
+ while (my $line = <$rfh>) {
+ next if $line =~ /^#/; # Strip out the helper comments
+ $final_msg .= $line;
+ }
+ close $rfh;
+ unlink($temp_msg_file); # Clean up
+ }
+
+ $final_msg =~ s/^\s+|\s+$//g; # Trim whitespace
+ return $final_msg;
+}
+