diff --git a/cmd/entire/main.go b/cmd/entire/main.go index caad2ea9d..c2493f30a 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -14,16 +14,8 @@ import ( ) func main() { - // Create context that cancels on interrupt - ctx, cancel := context.WithCancel(context.Background()) - - // Handle interrupt signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go func() { - <-sigChan - cancel() - }() + // Create context that cancels on interrupt signals + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) // Create and execute root command rootCmd := cli.NewRootCmd() diff --git a/prototype/.gitignore b/prototype/.gitignore new file mode 100644 index 000000000..3742fd774 --- /dev/null +++ b/prototype/.gitignore @@ -0,0 +1 @@ +checkpoint-viewer diff --git a/prototype/go.mod b/prototype/go.mod new file mode 100644 index 000000000..c41fb09ef --- /dev/null +++ b/prototype/go.mod @@ -0,0 +1,3 @@ +module github.com/entireio/checkpoint-viewer + +go 1.25 diff --git a/prototype/index.html b/prototype/index.html new file mode 100644 index 000000000..69a9a138c --- /dev/null +++ b/prototype/index.html @@ -0,0 +1,730 @@ + + + + + +Checkpoint Viewer + + + +
+
+

Checkpoint Viewer

+ ... +
auto-refresh 3s
+
+ +
+ +
+
+
+
+
+
+ + + + diff --git a/prototype/main.go b/prototype/main.go new file mode 100644 index 000000000..72e50875a --- /dev/null +++ b/prototype/main.go @@ -0,0 +1,491 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +//go:embed index.html +var staticFS embed.FS + +// --- API response types --- + +// CheckpointResponse is what /api/checkpoints returns per commit. +type CheckpointResponse struct { + Hash string `json:"hash"` + ShortHash string `json:"short_hash"` + Subject string `json:"subject"` + Date string `json:"date"` + CheckpointID string `json:"checkpoint_id"` + RootMeta json.RawMessage `json:"root_metadata"` + Sessions []SessionOnBranch `json:"sessions"` +} + +// SessionOnBranch is a session stored on entire/checkpoints/v1. +type SessionOnBranch struct { + Index int `json:"index"` + Metadata json.RawMessage `json:"metadata"` + Files []string `json:"files"` +} + +// --- Worktree hash for shadow branch filtering --- + +// worktreeHash returns the 6-char hash suffix used in shadow branch names +// for the current working directory. Matches the CLI's checkpoint.HashWorktreeID(). +func worktreeHash() string { + // Determine worktreeID: empty for main worktree, name for linked worktrees. + gitPath := filepath.Join(".", ".git") + info, err := os.Stat(gitPath) + if err != nil { + return hashID("") + } + if info.IsDir() { + // Main worktree + return hashID("") + } + // Linked worktree: .git file contains "gitdir: .../.git/worktrees/" + content, err := os.ReadFile(gitPath) + if err != nil { + return hashID("") + } + line := strings.TrimSpace(string(content)) + const marker = ".git/worktrees/" + _, id, found := strings.Cut(line, marker) + if !found { + return hashID("") + } + return hashID(strings.TrimSuffix(id, "/")) +} + +func hashID(id string) string { + h := sha256.Sum256([]byte(id)) + return hex.EncodeToString(h[:])[:6] +} + +// --- Cached git common dir --- + +var ( + gitCommonDirOnce sync.Once + gitCommonDirVal string +) + +func gitCommonDir() string { + gitCommonDirOnce.Do(func() { + out, err := exec.Command("git", "rev-parse", "--git-common-dir").Output() + if err != nil { + log.Printf("warning: git rev-parse --git-common-dir failed: %v", err) + gitCommonDirVal = ".git" + return + } + gitCommonDirVal = strings.TrimSpace(string(out)) + }) + return gitCommonDirVal +} + +func currentBranch() string { + out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(out)) +} + +// mainBranch returns the name of the main/master branch. +func mainBranch() string { + // Try common names + for _, name := range []string{"main", "master"} { + if err := exec.Command("git", "rev-parse", "--verify", "--quiet", "refs/heads/"+name).Run(); err == nil { + return name + } + } + return "" +} + +// gitLog returns commits with checkpoint trailers, scoped to the current branch only. +func gitLog(limit int) []CheckpointResponse { + format := "%H%x00%s%x00%aI%x00%(trailers:key=Entire-Checkpoint,valueonly)" + + // Only show commits on the current branch (not in main). + // If we ARE on main, fall back to showing recent commits. + branch := currentBranch() + base := mainBranch() + var args []string + if base != "" && branch != base { + args = []string{"log", fmt.Sprintf("--format=%s", format), base + "..HEAD"} + } else { + args = []string{"log", fmt.Sprintf("--format=%s", format), fmt.Sprintf("-%d", limit)} + } + + out, err := exec.Command("git", args...).Output() + if err != nil { + log.Printf("git log failed: %v", err) + return nil + } + + var results []CheckpointResponse + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\x00", 4) + if len(parts) < 4 { + continue + } + cpID := strings.TrimSpace(parts[3]) + if cpID == "" { + continue + } + hash := parts[0] + short := hash + if len(short) > 7 { + short = short[:7] + } + results = append(results, CheckpointResponse{ + Hash: hash, + ShortHash: short, + Subject: parts[1], + Date: parts[2], + CheckpointID: cpID, + }) + } + return results +} + +// cpBasePath returns the sharded base path for a checkpoint ID on the metadata branch. +func cpBasePath(cpID string) string { + if len(cpID) < 3 { + return cpID + } + return cpID[:2] + "/" + cpID[2:] +} + +// gitShowRaw reads a blob from a git ref and returns it as-is. +func gitShowRaw(ref string) ([]byte, error) { + out, err := exec.Command("git", "show", ref).Output() + if err != nil { + return nil, err + } + return out, nil +} + +// listTreePaths lists all file paths under a given tree path on a branch. +func listTreePaths(branch, dirPath string) []string { + // git ls-tree -r --name-only : + ref := branch + ":" + dirPath + out, err := exec.Command("git", "ls-tree", "-r", "--name-only", ref).Output() + if err != nil { + return nil + } + var paths []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + // paths are relative to dirPath, prepend it + paths = append(paths, dirPath+"/"+line) + } + } + return paths +} + +// readCheckpointSessions reads all per-session data for a checkpoint. +func readCheckpointSessions(cpID string) []SessionOnBranch { + base := cpBasePath(cpID) + var sessions []SessionOnBranch + for i := 0; i < 20; i++ { + sessionDir := fmt.Sprintf("%s/%d", base, i) + metaRef := fmt.Sprintf("entire/checkpoints/v1:%s/metadata.json", sessionDir) + raw, err := gitShowRaw(metaRef) + if err != nil { + break + } + files := listTreePaths("entire/checkpoints/v1", sessionDir) + sessions = append(sessions, SessionOnBranch{ + Index: i, + Metadata: json.RawMessage(raw), + Files: files, + }) + } + return sessions +} + +// readRootMeta reads the root metadata.json for a checkpoint. +func readRootMeta(cpID string) json.RawMessage { + ref := fmt.Sprintf("entire/checkpoints/v1:%s/metadata.json", cpBasePath(cpID)) + raw, err := gitShowRaw(ref) + if err != nil { + return nil + } + return json.RawMessage(raw) +} + +// readSessionStateFiles reads all .json files from .git/entire-sessions/ as raw JSON. +func readSessionStateFiles() []json.RawMessage { + dir := filepath.Join(gitCommonDir(), "entire-sessions") + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var sessions []json.RawMessage + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + // Validate it's proper JSON + if !json.Valid(data) { + continue + } + sessions = append(sessions, json.RawMessage(data)) + } + return sessions +} + +// --- Handlers --- + +func handleCheckpoints(w http.ResponseWriter, _ *http.Request) { + checkpoints := gitLog(50) + if checkpoints == nil { + checkpoints = []CheckpointResponse{} + } + + var wg sync.WaitGroup + for i := range checkpoints { + wg.Add(1) + go func(idx int) { + defer wg.Done() + cp := &checkpoints[idx] + cp.RootMeta = readRootMeta(cp.CheckpointID) + cp.Sessions = readCheckpointSessions(cp.CheckpointID) + }(i) + } + wg.Wait() + + writeJSON(w, checkpoints) +} + +func handleSessions(w http.ResponseWriter, _ *http.Request) { + sessions := readSessionStateFiles() + if sessions == nil { + sessions = []json.RawMessage{} + } + writeJSON(w, sessions) +} + +func handleBranch(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]string{"branch": currentBranch()}) +} + +// handleBlob serves raw file contents from the entire/checkpoints/v1 branch. +// GET /api/blob?path=///full.jsonl +func handleBlob(w http.ResponseWriter, r *http.Request) { + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "missing path parameter", http.StatusBadRequest) + return + } + // Sanitize: only allow paths under the checkpoints tree + if strings.Contains(path, "..") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + ref := "entire/checkpoints/v1:" + path + data, err := gitShowRaw(ref) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + // Detect content type + if strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".jsonl") { + w.Header().Set("Content-Type", "application/json") + } else if strings.HasSuffix(path, ".md") { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + w.Write(data) +} + +// handleShadowBranches lists entire/* shadow branches with their latest commit info. +func handleShadowBranches(w http.ResponseWriter, _ *http.Request) { + // List branches matching entire/* but not entire/checkpoints/* + out, err := exec.Command("git", "for-each-ref", "--format=%(refname:short)%00%(objectname:short)%00%(committerdate:iso-strict)%00%(subject)", "refs/heads/entire/").Output() + if err != nil { + writeJSON(w, []any{}) + return + } + + type BranchInfo struct { + Name string `json:"name"` + ShortHash string `json:"short_hash"` + Date string `json:"date"` + Subject string `json:"subject"` + } + + // Only show shadow branches for the current working directory. + // Shadow branch format: entire/- + wtHash := worktreeHash() + suffix := "-" + wtHash + + var branches []BranchInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\x00", 4) + if len(parts) < 4 { + continue + } + name := parts[0] + // Skip the checkpoints branch + if strings.HasPrefix(name, "entire/checkpoints") { + continue + } + // Only keep branches whose name ends with our worktree hash + if !strings.HasSuffix(name, suffix) { + continue + } + branches = append(branches, BranchInfo{ + Name: name, + ShortHash: parts[1], + Date: parts[2], + Subject: parts[3], + }) + } + if branches == nil { + branches = []BranchInfo{} + } + writeJSON(w, branches) +} + +// handleShadowTree lists files on a shadow branch at a given path. +func handleShadowTree(w http.ResponseWriter, r *http.Request) { + branch := r.URL.Query().Get("branch") + path := r.URL.Query().Get("path") + if branch == "" { + http.Error(w, "missing branch parameter", http.StatusBadRequest) + return + } + if strings.Contains(branch, "..") || strings.Contains(path, "..") { + http.Error(w, "invalid parameter", http.StatusBadRequest) + return + } + + ref := branch + if path != "" { + ref = branch + ":" + path + } + out, err := exec.Command("git", "ls-tree", "-r", "--name-only", ref).Output() + if err != nil { + writeJSON(w, []string{}) + return + } + + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + if path != "" { + files = append(files, path+"/"+line) + } else { + files = append(files, line) + } + } + } + if files == nil { + files = []string{} + } + writeJSON(w, files) +} + +// handleShadowBlob reads a file from a shadow branch. +func handleShadowBlob(w http.ResponseWriter, r *http.Request) { + branch := r.URL.Query().Get("branch") + path := r.URL.Query().Get("path") + if branch == "" || path == "" { + http.Error(w, "missing branch/path parameter", http.StatusBadRequest) + return + } + if strings.Contains(branch, "..") || strings.Contains(path, "..") { + http.Error(w, "invalid parameter", http.StatusBadRequest) + return + } + + ref := branch + ":" + path + data, err := gitShowRaw(ref) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".jsonl") { + w.Header().Set("Content-Type", "application/json") + } else if strings.HasSuffix(path, ".md") { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + w.Write(data) +} + +func writeJSON(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json") + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + if err := enc.Encode(data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) +} + +func main() { + port := flag.Int("port", 8080, "HTTP server port") + flag.Parse() + + if err := exec.Command("git", "rev-parse", "--git-dir").Run(); err != nil { + fmt.Fprintln(os.Stderr, "error: not a git repository. Run from within a git repo.") + os.Exit(1) + } + + http.HandleFunc("/api/checkpoints", handleCheckpoints) + http.HandleFunc("/api/sessions", handleSessions) + http.HandleFunc("/api/branch", handleBranch) + http.HandleFunc("/api/blob", handleBlob) + http.HandleFunc("/api/shadow-branches", handleShadowBranches) + http.HandleFunc("/api/shadow-tree", handleShadowTree) + http.HandleFunc("/api/shadow-blob", handleShadowBlob) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, err := staticFS.ReadFile("index.html") + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + }) + + addr := fmt.Sprintf(":%d", *port) + fmt.Fprintf(os.Stderr, "Checkpoint Viewer listening on http://localhost%s\n", addr) + log.Fatal(http.ListenAndServe(addr, nil)) +} diff --git a/prototype/screenshots/01-sessions-tab.png b/prototype/screenshots/01-sessions-tab.png new file mode 100644 index 000000000..0f50b5de6 Binary files /dev/null and b/prototype/screenshots/01-sessions-tab.png differ diff --git a/prototype/screenshots/02-session-expanded.png b/prototype/screenshots/02-session-expanded.png new file mode 100644 index 000000000..52d78164a Binary files /dev/null and b/prototype/screenshots/02-session-expanded.png differ diff --git a/prototype/screenshots/03-checkpoints-tab.png b/prototype/screenshots/03-checkpoints-tab.png new file mode 100644 index 000000000..f9d3f9ba4 Binary files /dev/null and b/prototype/screenshots/03-checkpoints-tab.png differ diff --git a/prototype/screenshots/04-checkpoint-expanded.png b/prototype/screenshots/04-checkpoint-expanded.png new file mode 100644 index 000000000..642e0b826 Binary files /dev/null and b/prototype/screenshots/04-checkpoint-expanded.png differ diff --git a/prototype/screenshots/05-blob-viewer.png b/prototype/screenshots/05-blob-viewer.png new file mode 100644 index 000000000..8da2534e1 Binary files /dev/null and b/prototype/screenshots/05-blob-viewer.png differ diff --git a/prototype/screenshots/06-shadow-branches-tab.png b/prototype/screenshots/06-shadow-branches-tab.png new file mode 100644 index 000000000..3e5cc4675 Binary files /dev/null and b/prototype/screenshots/06-shadow-branches-tab.png differ