diff options
| author | Sadeep Madurange <sadeep@asciimx.com> | 2026-03-27 21:20:07 +0800 |
|---|---|---|
| committer | Sadeep Madurange <sadeep@asciimx.com> | 2026-03-29 19:05:52 +0800 |
| commit | 42e68ccc9267907500f3e7d22bb7a31c790ef394 (patch) | |
| tree | 0c21df63ba8e95a9fac5c6803d23d043a5e701d2 | |
| parent | 3b9af22464af7f29110a5e0ff86c7b1f43fa6dad (diff) | |
| download | cvn-42e68ccc9267907500f3e7d22bb7a31c790ef394.tar.gz | |
Add, commit, log commands.
| -rw-r--r-- | vcx | 485 |
1 files changed, 397 insertions, 88 deletions
@@ -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; +} + |
