Prism VCS > Tree [f4699a2]

/main.go/

..
View Raw
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * This Source Code Form is “Incompatible With Secondary Licenses”, as
 * defined by the Mozilla Public License, v. 2.0. */

package main

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"github.com/gammazero/deque"
	"github.com/klauspost/compress/zstd"
)

func FindVCSDirectory() string {
	curDir, _ := os.Getwd()
	homeDir, _ := os.UserHomeDir()

	limit := 6 // Only allow to go up 6 levels of directories
	if curDir == homeDir {
		// If current directory is home directory, don't allow walking further up the filesystem
		limit = 0
	}

	checkPath := curDir
	result := path.Join(checkPath, ".prism")
	_, err := os.Stat(result)
	for errors.Is(err, fs.ErrNotExist) {
		checkPath = path.Join(checkPath, "..")
		limit--
		if limit == 0 || (checkPath == homeDir && curDir != homeDir) || checkPath == "/" || (len(checkPath) >= 3 && checkPath[1] == ':' && checkPath[2] == '/') {
			// Limit to going up 6 levels of directories, always stop at the home directory as long as it's not the current directory, and always stop at the root directory
			result = ""
			break
		}

		result = path.Join(checkPath, ".prism")
		_, err = os.Stat(result)
	}

	return result
}

func main() {
	if len(os.Args) == 1 {
		fmt.Printf("Usage:\n")
		fmt.Printf("  init [directory]       Initialize a new repository in the current or specified directory.\n")
		fmt.Printf("  status [path]          Show the status for entire worktree, or a given directory.\n")
		fmt.Printf("  stage            Add a file or directory to staging.\n")
		fmt.Printf("  unstage          Remove a file or directory from staging. Does not delete form working directory.\n")
		fmt.Printf("  commit        Record staged files to the repository.\n")
		fmt.Printf("  log [path]             Show commit history for all changes or for a specific file.\n")
		fmt.Printf("  diff                   Show changes between last commit and current staged changes.\n") // TODO
		fmt.Printf("  about                  Show prism software license.\n")
		fmt.Printf("\n")
		fmt.Printf("  cat-file         Display contents of a repo object.\n")
		fmt.Printf("  check                  Check repository for integrity.\n")
		fmt.Printf("  orphans                Show all orphaned objects.\n")
		fmt.Printf("  prune                  Remove orphaned objects.\n")
		fmt.Printf("  edit-last-commit [msg] Edit the message of your last commit.\n")

		// fmt.Printf("  find-commits-with-tree-hash   Find commits that have the given tree hash.\n")
		// fmt.Printf("  lineage                      Show lineage of commits (not yet implemented).\n")
		// fmt.Printf("  rewrite-history              Rewrite repository history with improved metadata.\n")
		return
	}

	command := os.Args[1]
	switch command {
	case "init":
		initCmd()
	case "status":
		directory := ""
		if len(os.Args) >= 3 {
			directory = os.Args[2]
		}
		statusCmd(directory)
	case "stage", "add":
		stageCmd()
	case "unstage":
		unstageCmd()
	case "commit":
		commitCmd()
	case "lineage":
		fmt.Printf("Lineages are not yet implemented.\n")
	case "log":
		logCmd()
	case "diff":
		diffCmd()
	case "about":
		aboutCmd()
	case "cat-file":
		catFileCmd()
	case "edit-last-commit":
		fixLastCommitCmd()
	case "orphans":
		OrphanedObjects(false)
	case "prune":
		OrphanedObjects(true)
	case "rewrite-history":
		rewriteHistoryCmd()
	case "find-commits-with-tree-hash":
		FindCommitsWithTreeHash(os.Args[2])
	case "check":
		if len(os.Args) >= 3 && (os.Args[2] == "--verbose" || os.Args[2] == "-v") {
			CheckRepoCmd(true)
		} else {
			CheckRepoCmd(false)
		}
	default:
		fmt.Printf("Unknown command.\n")
	}
}

func aboutCmd() {
	fmt.Printf("prism - Version control through a clearer lens\n")
	fmt.Printf("Version: 0.1.0\n")
	fmt.Printf("Copyright (c) 2025 Christian Lee Seibold\n\n")
	fmt.Printf(`BSD 3-Clause License

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
	list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
	this list of conditions and the following disclaimer in the documentation
	and/or other materials provided with the distribution.

3. Neither the name of the copyright holder nor the names of its
	contributors may be used to endorse or promote products derived from
	this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

`)

} func initCmd() { vcsDirectory := FindVCSDirectory() // Set up VCS directories if vcsDirectory == "" { initDirectory, _ := os.Getwd() if len(os.Args) > 1 { initDirectory = path.Clean(os.Args[2]) } entries, err := os.ReadDir(initDirectory) if err != nil { fmt.Printf("Error: %v\n", err) return } defaultBranchName := "main" if info, err := os.Stat(path.Join(initDirectory, defaultBranchName)); err == nil && info.IsDir() { // Check if directory is empty entries, _ := os.ReadDir(path.Join(initDirectory, defaultBranchName)) if len(entries) > 0 { fmt.Printf("Error: Directory '%s' is not empty and conflicts with naming of default worktree. Cannot initialize repository.\n", path.Join(initDirectory, defaultBranchName)) return } } // Create main VCS directories vcsDirectory = path.Join(initDirectory, ".prism") os.Mkdir(vcsDirectory, 0755) os.Mkdir(path.Join(vcsDirectory, "objects"), 0755) os.Mkdir(path.Join(vcsDirectory, "refs"), 0755) os.Mkdir(path.Join(vcsDirectory, "refs/branches"), 0755) // Checkout the main branch into a worktree os.Mkdir(path.Join(initDirectory, defaultBranchName), 0755) os.Mkdir(path.Join(vcsDirectory, "worktrees"), 0755) os.Mkdir(path.Join(vcsDirectory, "worktrees", defaultBranchName), 0755) os.WriteFile(path.Join(vcsDirectory, "worktrees", defaultBranchName, "staging"), []byte(""), 0644) os.WriteFile(path.Join(vcsDirectory, "worktrees", defaultBranchName, "head"), []byte("ref: refs/branches/"+defaultBranchName+"\n"), 0644) fmt.Printf("Initialized repository with '%s' branch. Worktree created at '%s/'.\n", defaultBranchName, path.Join(initDirectory, defaultBranchName)) if len(entries) > 0 { fmt.Printf("There are files in the current directory. Do you want to move them into the worktree? [Y/N] ") moveFilesIntoWorktree := false reader := bufio.NewReader(os.Stdin) for { input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) if input == "Y" || input == "y" { moveFilesIntoWorktree = true break } else if input == "N" || input == "n" { break } fmt.Printf("Please enter Y or N: ") } if moveFilesIntoWorktree { for _, entry := range entries { if entry.Name() == ".prism" { continue } os.Rename(entry.Name(), path.Join(initDirectory, defaultBranchName, entry.Name())) } fmt.Printf("Files have been moved into the worktree.\n") } } } else { fmt.Printf("Already inside a repository.\n") } } // Can be given an optional directory in which it will only output the status for that directory func statusCmd(directory string) { vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } if directory == "" { directory = currentWorktree.WorkingDirectoryPath() } // Make directory an absolute path. Get the path relative to the worktree's path for use in the staging index entries map. directory, _ = filepath.Abs(directory) relativeDirectory, _ := filepath.Rel(currentWorktree.WorkingDirectoryPath(), directory) relativeDirectory = filepath.ToSlash(relativeDirectory) // Print staging area idx := currentWorktree.ReadIndex() currentBranch := currentWorktree.GetCurrentBranch() fmt.Printf("Status for '%s' worktree. On branch '%s'.\n", currentWorktree.Name, currentBranch) for _, entry := range idx.IterateStaged() { fmt.Printf(" \033[32mstaged: \033[39m%s\n", entry.name) } // Print stuff that needs to be staged by checking if ModTime has changed, and if so, then checking if hash has changed. fmt.Printf("\nChanges not staged:\n") entries, _ := os.ReadDir(directory) for _, e := range entries { info, _ := e.Info() if e.Name() == ".prism" || e.IsDir() { continue } // Check if already in index, and if so, if modtime has changed entry, exists := idx[path.Join(relativeDirectory, e.Name())] if exists && entry.modtime.Before(info.ModTime().Truncate(time.Second)) { // Check if hash has changed file_contents, err := os.ReadFile(filepath.Join(directory, e.Name())) if err != nil { continue } header := fmt.Sprintf("blob %d\000", len(file_contents)) obj_buf := bytes.NewBuffer(make([]byte, 0, len(header)+len(file_contents))) obj_buf.WriteString(header) obj_buf.Write(file_contents) obj_hash := SumHash(obj_buf.Bytes()) if entry.hash != obj_hash { fmt.Printf(" \033[33mmodified: \033[39m%s\n", path.Join(relativeDirectory, e.Name())) } else { // TODO: If hash has not changed, then update the modtime of the staging index entry? } } else if !exists { fmt.Printf(" \033[31mnew: \033[39m%s\n", path.Join(relativeDirectory, e.Name())) } } } func stageCmd() { if len(os.Args) < 3 || os.Args[2] == "" { fmt.Printf("Must specify a path.\n") return } vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } // Stage a file or directory givenPath := os.Args[2] if strings.HasSuffix(givenPath, ".prism") { fmt.Printf("Cannot stage .prism directory.\n") return } // Make directory an absolute path. Get the path relative to the worktree's path for use in the staging index entries map. givenPath, _ = filepath.Abs(givenPath) relativePath, _ := filepath.Rel(currentWorktree.WorkingDirectoryPath(), givenPath) relativePath = filepath.ToSlash(relativePath) currentWorktree.WriteLock() defer currentWorktree.WriteUnlock() stat, err := os.Stat(givenPath) if errors.Is(err, fs.ErrNotExist) { fmt.Printf("Error: File does not exist.\n") return } if stat.IsDir() { // Walk the directory to stage every file and sub-directory. } else { // Add object to objects folder, and compress it file_contents, _ := os.ReadFile(givenPath) header := fmt.Sprintf("blob %d\000", len(file_contents)) obj_buf := bytes.NewBuffer(make([]byte, 0, len(header)+len(file_contents))) obj_buf.WriteString(header) obj_buf.Write(file_contents) obj_hash := SumHash(obj_buf.Bytes()) var buf bytes.Buffer zw, _ := zstd.NewWriter(&buf, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) zw.Write(obj_buf.Bytes()) zw.Close() // Add to or update in staging index idx := currentWorktree.ReadIndex() status := "s" entry, exists := idx[relativePath] if exists && entry.hash == obj_hash { // The file was already in the index and has already been staged. No need to write the file. Change status to "c". status = "c" } else if exists && entry.status == "s" { // If a previous version was already staged, but not comitted, then remove that old object os.WriteFile(path.Join(currentWorktree.vcsDirectory, "objects", obj_hash.HexString()), buf.Bytes(), 0644) os.Remove(path.Join(currentWorktree.vcsDirectory, "objects", entry.hash.HexString())) } else { // Otherwise, write the new object, and keep the status as staged. os.WriteFile(path.Join(currentWorktree.vcsDirectory, "objects", obj_hash.HexString()), buf.Bytes(), 0644) } isExecutable := false if (stat.Mode().Perm() & 0100) != 0 { isExecutable = true } if isExecutable { idx[relativePath] = IndexEntry{status, "100755", relativePath, obj_hash, stat.ModTime().Truncate(time.Second)} } else { idx[relativePath] = IndexEntry{status, "100644", relativePath, obj_hash, stat.ModTime().Truncate(time.Second)} } currentWorktree.WriteIndex(idx) } } func unstageCmd() { if len(os.Args) < 3 || os.Args[2] == "" { fmt.Printf("Must specify a path.\n") return } vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } // Unstage a file or directory, removing it from the staging index. givenPath := os.Args[2] if givenPath == ".prism" { return } // Make directory an absolute path. Get the path relative to the worktree's path for use in the staging index entries map. givenPath, _ = filepath.Abs(givenPath) relativePath, _ := filepath.Rel(currentWorktree.WorkingDirectoryPath(), givenPath) relativePath = filepath.ToSlash(relativePath) if relativePath == "" || relativePath == "." { fmt.Printf("Cannot unstage entire worktree. Please unstage each file/folder directly.\n") return } currentWorktree.WriteLock() defer currentWorktree.WriteUnlock() stat, err := os.Stat(givenPath) if errors.Is(err, fs.ErrNotExist) { fmt.Printf("Error: File does not exist.\n") return } if stat.IsDir() { // Walk the directory to stage every file and sub-directory. } else { idx := currentWorktree.ReadIndex() if entry, exists := idx[relativePath]; !exists || entry.status != "s" { fmt.Printf("Cannot unstage a file that has not been staged yet.\n") return } objHashToDelete := idx[relativePath].hash // Check if relativePath is in the head commit. headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(headFile), " ") headCommitStr, _ := os.ReadFile(filepath.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(headCommitStr))) headCommit := DecodeCommit(vcsDirectory, headCommitHash) headTree := DecodeTree(vcsDirectory, headCommit.TreeHash) entry := headTree.FindEntryName(relativePath) if entry == (TreeEntry{}) { // If entry not found in last commit, just do the unstaging by removing the object from the index delete(idx, relativePath) } else { // If found in last commit, revert the index entry to that of the last commit if entry.t == "blob" { idx[relativePath] = IndexEntry{"c", "100644", relativePath, entry.hash, headCommit.Date} } else if entry.t == "tree" { // TODO } } currentWorktree.WriteIndex(idx) // Delete the unstaged object, but only if it's not the same hash as that in the last commit. if entry.hash != objHashToDelete { os.Remove(filepath.Join(vcsDirectory, "objects", objHashToDelete.HexString())) } fmt.Printf("File '%s' has been unstaged.\n", relativePath) } } func catFileCmd() { vcsDirectory := FindVCSDirectory() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } hash := ParseHashHex(os.Args[2]) file_contents, _ := os.ReadFile(path.Join(vcsDirectory, "objects", hash.HexString())) zr, _ := zstd.NewReader(bytes.NewReader(file_contents)) result := new(bytes.Buffer) result.ReadFrom(zr) zr.Close() header, content, _ := strings.Cut(result.String(), "\000") fmt.Printf("Header: %s\n", header) if strings.HasPrefix(header, "tree") { // Pretty print tree treeObj := DecodeTree(vcsDirectory, hash) // TODO: Loads the file again. for _, e := range treeObj.entries { fmt.Printf("%s %s %s\n", e.t, e.hash.HexString(), e.name) } } else { //blob := DecodeBlob(vcsDirectory, hash) fmt.Printf("%s\n", content) } } func commitCmd() { commitTime := time.Now() vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } author := "Christian Lee Seibold " comitter := author idx := currentWorktree.ReadIndex() if idx.CountStaged() == 0 { fmt.Printf("Nothing has been staged.\n") return } // Message provided on cli commitMessage := "" if len(os.Args) >= 3 { commitMessage = strings.Join(os.Args[2:], " ") } else { // Otherwise, create a file for the commit message and open it in the editor, waiting for the editor to close (or it to close the file) msgfile := path.Join(currentWorktree.WorktreeSettingsPath(), "COMMIT_EDITMSG") os.WriteFile(msgfile, []byte{}, 0644) editor := os.Getenv("EDITOR") editor_parts := strings.Split(editor, " ") if len(editor) == 0 { editor = "/usr/bin/editor" } editor_parts = append(editor_parts, msgfile) cmd := exec.Command(editor_parts[0], editor_parts[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() msgBytes, _ := os.ReadFile(msgfile) commitMessage = strings.TrimSpace(string(msgBytes)) } if commitMessage == "" { fmt.Printf("Canceling commit. No message provided.") return } currentWorktree.Commit(author, comitter, commitMessage, commitTime) } // Prints commit history of project, or a given file/directory path. func logCmd() { vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } // Check if we've been given a path. If so, we will only print commits that have modified this path (given the current state of the worktree). // TODO: If the current state of the worktree has introduced an entirely new file with the same pathname as a previous file that was deleted, // it will/should take that into account (i.e., we keep track of renames, moves, deletions, and additions of files). This means we must // first read the staging index to see what file we are actually talking about and how it relates to the project's history. We will also // stop at the commit that is an ancestor to the worktree's current head and at which the filepath was first added. givenPath := "" relativePath := "" if len(os.Args) > 2 { givenPath = os.Args[2] // Make givenPath an absolute path. Get the path relative to the worktree's path. givenPath, _ = filepath.Abs(givenPath) relativePath, _ = filepath.Rel(currentWorktree.WorkingDirectoryPath(), givenPath) relativePath = filepath.ToSlash(relativePath) fmt.Printf("Printing history for file '%s'.\n\n", relativePath) } // Get the head file, get the branch ref, and print the commit messages headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") if _, err := os.Stat(path.Join(vcsDirectory, head)); errors.Is(err, fs.ErrNotExist) { _, branchName := path.Split(head) fmt.Printf("Your current branch '%s' does not have any commits yet.\n", branchName) return } // Get commit from the current head contents, _ := os.ReadFile(path.Join(vcsDirectory, head)) currentCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) commit := DecodeCommit(vcsDirectory, currentCommitHash) commitTree := DecodeTree(vcsDirectory, commit.TreeHash) for { parentCommit := DecodeCommit(vcsDirectory, commit.ParentHash) parentCommitTree := DecodeTree(vcsDirectory, parentCommit.TreeHash) printCommit := false // Check if the givenPath has been changed in commit compared to parent commit by checking if the hash has changed. If so, print the commit. if givenPath != "" && !parentCommit.Hash.IsEmpty() { commitEntry := commitTree.FindEntryName(relativePath) // TODO: This doesn't work with nested trees yet! parentCommitEntry := parentCommitTree.FindEntryName(relativePath) if commitEntry.hash.IsEmpty() { // If our current working file was added after this commit, then all previous commits with // this file name are not the same file. So completely break from here. break } else if parentCommitEntry.hash.IsEmpty() == !commitEntry.hash.IsEmpty() { // The file was added or deleted in this commit. printCommit = true } else if parentCommitEntry.hash != commitEntry.hash { // The file's contents was changed. // TODO: Make this work with renames and moves. printCommit = true } else if parentCommitEntry.hash == commitEntry.hash { // If no change in file, skip. } } else { printCommit = true } if printCommit { if (commit.Date != time.Time{}) { fmt.Printf("\033[38;5;184mcommit %s\033[39m\033[49m\nAuthor: %s\nDate: %s\n\n%s\n", commit.Hash.HexString(), commit.Author, commit.Date.Format(time.RFC1123Z), commit.Message) } else { fmt.Printf("\033[38;5;184mcommit %s\033[39m\033[49m\nAuthor: %s\n\n%s\n", commit.Hash.HexString(), commit.Author, commit.Message) } if !strings.HasSuffix(commit.Message, "\n") { fmt.Printf("\n") } } if parentCommit.Hash.IsEmpty() { break } commit = parentCommit commitTree = parentCommitTree } } func diffCmd() { vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } // Get the head file, get the branch ref, and print the commit messages headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") if _, err := os.Stat(path.Join(vcsDirectory, head)); errors.Is(err, fs.ErrNotExist) { _, branchName := path.Split(head) fmt.Printf("Your current branch '%s' does not have any commits yet.\n", branchName) return } // Get Index and Commit from the current head idx := currentWorktree.ReadIndex() contents, _ := os.ReadFile(path.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) headCommit := DecodeCommit(vcsDirectory, headCommitHash) headCommitTree := DecodeTree(vcsDirectory, headCommit.TreeHash) // Go through each staged file of index. // TODO: Make this track file renames and moves. objectStore := ObjectStore{vcsDirectory: vcsDirectory} for _, stagedEntry := range idx.IterateStaged() { headEntry := headCommitTree.FindEntryName(stagedEntry.name) // If the entry doesn't exist in the head commit, then it's a new file if headEntry.hash.IsEmpty() { fmt.Printf("diff -u a/null b/%s\n", stagedEntry.name) fmt.Printf("new file mode %s\n", stagedEntry.mode) fmt.Printf("--- /dev/null\n") fmt.Printf("+++ b/%s\n", stagedEntry.name) // Create a temporary file with the staged content stageTempFile, err := os.CreateTemp("", "staged-*-"+stagedEntry.name) if err != nil { fmt.Printf("Error creating temp file: %v\n", err) continue } stageReader, err := objectStore.GetObject(stagedEntry.hash) if err != nil { fmt.Printf("Error reading staged file: %v\n", err) continue } io.Copy(stageTempFile, stageReader) stageTempFile.Sync() stageTempFile.Close() stageReader.Close() // Run diff to show changes // Check if GIT_EXTERNAL_DIFF environment variable is set externalDiff := os.Getenv("GIT_EXTERNAL_DIFF") if externalDiff != "" { // Use the external diff tool cmd := exec.Command(externalDiff, "/dev/null", stageTempFile.Name()) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() } else { // Fall back to regular diff command cmd := exec.Command("diff", "--color", "--ignore-trailing-space", "-u", "/dev/null", stageTempFile.Name()) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() } // Remove Temporary Files os.Remove(filepath.Join(os.TempDir(), stageTempFile.Name())) } else if headEntry.hash != stagedEntry.hash { // The file was modified fmt.Printf("diff -u a/%s b/%s\n", stagedEntry.name, stagedEntry.name) fmt.Printf("--- a/%s\n", stagedEntry.name) fmt.Printf("+++ b/%s\n", stagedEntry.name) // Create temporary files for both versions headTempFile, err := os.CreateTemp("", "head-*-"+stagedEntry.name) if err != nil { fmt.Printf("Error creating temp file: %v\n", err) continue } stageTempFile, err := os.CreateTemp("", "staged-*-"+stagedEntry.name) if err != nil { fmt.Printf("Error creating temp file: %v\n", err) continue } // Write head content to temp file headReader, err := objectStore.GetObject(headEntry.hash) if err != nil { fmt.Printf("Error reading head file: %v\n", err) continue } io.Copy(headTempFile, headReader) headTempFile.Sync() headTempFile.Close() headReader.Close() // Write staged content to temp file stageReader, err := objectStore.GetObject(stagedEntry.hash) if err != nil { fmt.Printf("Error reading staged file: %v\n", err) continue } io.Copy(stageTempFile, stageReader) stageTempFile.Sync() stageTempFile.Close() stageReader.Close() // Run diff to show changes // Check if GIT_EXTERNAL_DIFF environment variable is set externalDiff := os.Getenv("GIT_EXTERNAL_DIFF") if externalDiff != "" { // Use the external diff tool cmd := exec.Command(externalDiff, headTempFile.Name(), stageTempFile.Name()) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() } else { // Fall back to regular diff command cmd := exec.Command("diff", "--color", "--ignore-trailing-space", "-u", headTempFile.Name(), stageTempFile.Name()) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Run() } // Remove temporary files os.Remove(headTempFile.Name()) os.Remove(stageTempFile.Name()) } } } func fixLastCommitCmd() { vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() // Allows you to fix the message of the last commit you made. This only works if HEAD points to the last commit on the branch, the commit you want to edit. if vcsDirectory == "" { fmt.Printf("Must init repository first.\n") return } else if currentWorktree == nil { fmt.Printf("Not in worktree.\n") return } // Get the head file, get the branch ref, and print the commit messages currentWorktree.WriteLock() defer currentWorktree.WriteUnlock() headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") if _, err := os.Stat(path.Join(vcsDirectory, head)); errors.Is(err, fs.ErrNotExist) { _, branchName := path.Split(head) fmt.Printf("Your current branch '%s' does not have any commits yet.\n", branchName) return } // Make sure HEAD points to a branch ref if !strings.HasPrefix(head, "refs/branches/") { fmt.Printf("You can only fix the last commit of a branch. HEAD must be pointing to a branch.\n") return } contents, _ := os.ReadFile(path.Join(vcsDirectory, head)) oldCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) commit := DecodeCommit(vcsDirectory, oldCommitHash) commitMessage := "" if len(os.Args) >= 3 { commitMessage = strings.Join(os.Args[2:], " ") } else { // Otherwise, create a file for the commit message and open it in the editor, waiting for the editor to close (or it to close the file) msgfile := path.Join(currentWorktree.WorktreeSettingsPath(), "COMMIT_EDITMSG") os.WriteFile(msgfile, []byte(commit.Message), 0644) editor := os.Getenv("EDITOR") editor_parts := strings.Split(editor, " ") if len(editor) == 0 { editor = "/usr/bin/editor" } editor_parts = append(editor_parts, msgfile) cmd := exec.Command(editor_parts[0], editor_parts[1:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err := cmd.Run() if err != nil { fmt.Printf("Error: %s\n", err.Error()) return } msgBytes, _ := os.ReadFile(msgfile) commitMessage = strings.TrimSpace(string(msgBytes)) } commit.Message = commitMessage commit_contents := commit.ToString() header := fmt.Sprintf("commit %d\000", len(commit_contents)) obj_buf := bytes.NewBuffer(make([]byte, 0, len(header)+len([]byte(commit_contents)))) obj_buf.WriteString(header) obj_buf.WriteString(commit_contents) obj_hash := SumHash(obj_buf.Bytes()) commit.Hash = obj_hash var buf bytes.Buffer zw, _ := zstd.NewWriter(&buf, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) zw.Write(obj_buf.Bytes()) zw.Close() os.WriteFile(path.Join(currentWorktree.vcsDirectory, "objects", obj_hash.HexString()), buf.Bytes(), 0644) // Remove old commit object os.Remove(path.Join(currentWorktree.vcsDirectory, "objects", oldCommitHash.HexString())) // Set the branch to the new commit hash. os.WriteFile(path.Join(currentWorktree.vcsDirectory, head), []byte(obj_hash.HexString()), 0644) } // caseInsensitiveStringCompare compares two strings without allocating. // The result will be 0 if a == b, -1 if a < b, and +1 if a > b. func caseInsensitiveStringCompare(a, b string) int { for { if len(b) == 0 && len(a) == 0 { return 0 // Same length and same runes } else if len(b) == 0 { return 1 // a is bigger (has more runes) } else if len(a) == 0 { return -1 // b is bigger (has more runes) } c, sizec := utf8.DecodeRuneInString(a) d, sized := utf8.DecodeRuneInString(b) lowerc := unicode.ToLower(c) lowerd := unicode.ToLower(d) if lowerc < lowerd { return -1 } else if lowerc > lowerd { return 1 } a = a[sizec:] b = b[sized:] } } func rewriteHistoryCmd() { yesterday := time.Now().Add(-24 * time.Hour) dontDelete := make(map[string]bool) vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() currentWorktree.WriteLock() defer currentWorktree.WriteUnlock() // Add all objects in current staging index to dontDelete map idx := currentWorktree.ReadIndex() for _, entry := range idx { dontDelete[entry.hash.HexString()] = true } // Get the main branch and go down the line to collect all commits deque := deque.Deque[CommitObject]{} headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") contents, _ := os.ReadFile(filepath.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) headCommit := DecodeCommit(vcsDirectory, headCommitHash) deque.PushBack(headCommit) currentCommit := headCommit for !currentCommit.ParentHash.IsEmpty() { currentCommit = DecodeCommit(vcsDirectory, currentCommit.ParentHash) deque.PushBack(currentCommit) } // Go through the length of the deque, get each commit using deque.PopBack() objectStore := ObjectStore{vcsDirectory: vcsDirectory} parentHash := Hash{} parentCommit := CommitObject{} for range deque.Len() { commit := deque.PopBack() // Get the commit's tree and convert to new format tree := DecodeTree(vcsDirectory, commit.TreeHash) newTreeObjBytes := tree.Encode() treeHeader := fmt.Sprintf("tree %d\000", len(newTreeObjBytes)) newBuf := bytes.NewBuffer(make([]byte, 0, len(treeHeader)+len(newTreeObjBytes))) newBuf.WriteString(treeHeader) newBuf.Write(newTreeObjBytes) newTreeHash := SumHash(newBuf.Bytes()) err := objectStore.WriteObject(newTreeHash, bytes.NewReader(newBuf.Bytes())) if err != nil && !errors.Is(err, ErrObjectExists) { panic(err) } else if errors.Is(err, ErrObjectExists) && newTreeHash == parentCommit.TreeHash && dontDelete[newTreeHash.HexString()] == true { fmt.Printf("Found commit with tree %s same as direct parent. Removing commit (old hash %s) from history.\n", newTreeHash.HexString(), commit.Hash.HexString()) continue } dontDelete[newTreeHash.HexString()] = true // Go through every entry in tree and add their hashes to the dontDelete map for _, e := range tree.entries { dontDelete[e.hash.HexString()] = true } // If commit doesn't have a date, give it one if commit.Date == (time.Time{}) { commit.Date = yesterday } // Generate new commit with updated date, parent, tree, and new hash commit.TreeHash = newTreeHash commit.ParentHash = parentHash newCommitStr := commit.ToString() commitHeader := fmt.Sprintf("commit %d\000", len(newCommitStr)) newCommitBuf := bytes.NewBuffer(make([]byte, 0, len(commitHeader)+len([]byte(newCommitStr)))) newCommitBuf.WriteString(commitHeader) newCommitBuf.WriteString(newCommitStr) newCommitHash := SumHash(newCommitBuf.Bytes()) err = objectStore.WriteObject(newCommitHash, bytes.NewReader(newCommitBuf.Bytes())) if err != nil && !errors.Is(err, ErrObjectExists) { panic(err) } dontDelete[newCommitHash.HexString()] = true commit.Hash = newCommitHash parentHash = newCommitHash parentCommit = commit } // Set the branch to the last commit. os.WriteFile(filepath.Join(currentWorktree.vcsDirectory, head), []byte(parentHash.HexString()), 0644) // Delete everything that's not in the dontDelete map objectFiles, _ := os.ReadDir(path.Join(currentWorktree.vcsDirectory, "objects")) for _, object := range objectFiles { if _, exists := dontDelete[object.Name()]; !exists { os.Remove(path.Join(currentWorktree.vcsDirectory, "objects", object.Name())) } } } func OrphanedObjects(prune bool) { nonOrphaned := make(map[string]bool) vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() // Add all objects in current staging index to nonOrphaned map idx := currentWorktree.ReadIndex() for _, entry := range idx { nonOrphaned[entry.hash.HexString()] = true } // Get the main branch and go down the line to collect all commits headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") contents, _ := os.ReadFile(filepath.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) headCommit := DecodeCommit(vcsDirectory, headCommitHash) currentCommit := headCommit for !currentCommit.Hash.IsEmpty() { nonOrphaned[currentCommit.Hash.HexString()] = true tree := DecodeTree(vcsDirectory, currentCommit.TreeHash) nonOrphaned[tree.hash.HexString()] = true for _, e := range tree.entries { nonOrphaned[e.hash.HexString()] = true } currentCommit = DecodeCommit(vcsDirectory, currentCommit.ParentHash) } orphansFound := false objectFiles, _ := os.ReadDir(path.Join(currentWorktree.vcsDirectory, "objects")) for _, object := range objectFiles { if _, exists := nonOrphaned[object.Name()]; !exists { orphansFound = true if prune { fmt.Printf("Pruned object %s.\n", object.Name()) os.Remove(path.Join(currentWorktree.vcsDirectory, "objects", object.Name())) } else { fmt.Printf("%s\n", object.Name()) } } } if !prune && orphansFound { fmt.Printf("\nRun `prism prune` to delete these orphaned objects from the repo.\n") } else if !orphansFound { fmt.Printf("No orphans found.\n") } } // Finds the commits that have the given tree hash. Useful for finding commits with duplicate trees. func FindCommitsWithTreeHash(givenHashString string) { givenHash := ParseHashHex(givenHashString) vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() currentWorktree.WriteLock() defer currentWorktree.WriteUnlock() // Get the main branch and go down the line to collect all commits headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") contents, _ := os.ReadFile(filepath.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) headCommit := DecodeCommit(vcsDirectory, headCommitHash) currentCommit := headCommit for !currentCommit.Hash.IsEmpty() { if currentCommit.TreeHash == givenHash { fmt.Printf("Found commit %s with tree hash.\n", currentCommit.Hash.HexString()) } currentCommit = DecodeCommit(vcsDirectory, currentCommit.ParentHash) } } // Check repository for corruption by making sure all objects that are linked to are stored on the filesystem func CheckRepoCmd(verbose bool) { nonOrphaned := make(map[Hash]bool) missingObject := make(map[Hash]bool) vcsDirectory := FindVCSDirectory() currentWorktree := OpenCurrentWorktree(vcsDirectory) defer currentWorktree.Close() // Add all objects in current staging index to nonOrphaned map, and check that they exist idx := currentWorktree.ReadIndex() for _, entry := range idx { nonOrphaned[entry.hash] = true if _, err := os.Stat(filepath.Join(vcsDirectory, "objects", entry.hash.HexString())); errors.Is(err, fs.ErrNotExist) { missingObject[entry.hash] = true if verbose { fmt.Printf("Missing blob %s\n", entry.hash.HexString()) } } } // Get the main branch and go down the line to get all commits. Check that they, their trees, and the objects of the trees all exist. headFile := currentWorktree.ReadHead() _, head, _ := strings.Cut(strings.TrimSpace(string(headFile)), " ") contents, _ := os.ReadFile(filepath.Join(vcsDirectory, head)) headCommitHash := ParseHashHex(strings.TrimSpace(string(contents))) currentCommitHash := headCommitHash for !currentCommitHash.IsEmpty() { currentCommit := DecodeCommit(vcsDirectory, currentCommitHash) if currentCommit.Hash.IsEmpty() { missingObject[currentCommitHash] = true if verbose { fmt.Printf("Missing commit %s\n", currentCommitHash.HexString()) } break } nonOrphaned[currentCommit.Hash] = true tree := DecodeTree(vcsDirectory, currentCommit.TreeHash) nonOrphaned[tree.hash] = true if tree.hash.IsEmpty() { missingObject[currentCommit.TreeHash] = true if verbose { fmt.Printf("Missing tree %s.\n", currentCommit.TreeHash.HexString()) } } else { for _, e := range tree.entries { nonOrphaned[e.hash] = true if _, err := os.Stat(filepath.Join(vcsDirectory, "objects", e.hash.HexString())); errors.Is(err, fs.ErrNotExist) { missingObject[e.hash] = true if verbose { fmt.Printf("Missing blob %s\n", e.hash.HexString()) } } } } currentCommitHash = currentCommit.ParentHash } // TODO: Orphaned Commits - commits that are not pointed to by anything, but may point themselves to parents. // This can happen if a commit object is missing (and therefore a link from it to its parent is missing). // TODO: Orphaned Tree Entries, when tree objects they are pointed from are missing. store := ObjectStore{vcsDirectory} orphansFound := false objectFiles, _ := os.ReadDir(path.Join(currentWorktree.vcsDirectory, "objects")) // Try to find commits and tree entries and check their trees for _, object := range objectFiles { objHash := ParseHashHex(object.Name()) objReader, _ := store.GetObject(objHash) if _, exists := nonOrphaned[objHash]; !exists { if objReader.Type == ObjectType_Commit { objReader.Close() commitHash := objHash for !commitHash.IsEmpty() { commit := DecodeCommit(vcsDirectory, commitHash) if commit.Hash.IsEmpty() { missingObject[commitHash] = true if verbose { fmt.Printf("Missing commit %s\n", commitHash.HexString()) } break } tree := DecodeTree(vcsDirectory, commit.TreeHash) nonOrphaned[tree.hash] = true if tree.hash.IsEmpty() { missingObject[commit.TreeHash] = true if verbose { fmt.Printf("Missing tree %s\n", commit.TreeHash.HexString()) } } else { for _, e := range tree.entries { nonOrphaned[e.hash] = true if _, err := os.Stat(filepath.Join(vcsDirectory, "objects", e.hash.HexString())); errors.Is(err, fs.ErrNotExist) { missingObject[e.hash] = true if verbose { fmt.Printf("Missing blob %s\n", e.hash.HexString()) } } } } if !commit.ParentHash.IsEmpty() { nonOrphaned[commit.ParentHash] = true } commitHash = commit.ParentHash } } else if objReader.Type == ObjectType_Tree { tree := DecodeTree(vcsDirectory, objHash) nonOrphaned[tree.hash] = true for _, e := range tree.entries { nonOrphaned[e.hash] = true if _, err := os.Stat(filepath.Join(vcsDirectory, "objects", e.hash.HexString())); errors.Is(err, fs.ErrNotExist) { missingObject[e.hash] = true if verbose { fmt.Printf("Missing blob %s\n", e.hash.HexString()) } } } } } } // Print out orphans for _, object := range objectFiles { objHash := ParseHashHex(object.Name()) if _, exists := nonOrphaned[objHash]; !exists { orphansFound = true fmt.Printf("Orphaned: %s\n", object.Name()) } } if orphansFound { fmt.Printf("\nRun `prism prune` to delete these orphaned objects from the repo.\n") } else if !orphansFound { fmt.Printf("No orphans found.\n") } fmt.Printf("Found %d missing objects.\n", len(missingObject)) for obj, _ := range missingObject { fmt.Printf("%s\n", obj.HexString()) } }