diff --git a/internal/changeset/display.go b/internal/changeset/display.go new file mode 100644 index 0000000..93c5de5 --- /dev/null +++ b/internal/changeset/display.go @@ -0,0 +1,127 @@ +package changeset + +import ( + "fmt" + "io" + "strings" +) + +const maxDisplayChanges = 20 + +// PrintSummary prints a human-readable change summary to the writer. +func PrintSummary(w io.Writer, cs *SessionChangeset) { + if cs == nil { + return + } + + totalChanges := 0 + for _, mc := range cs.MountChanges { + totalChanges += len(mc.Changes) + } + + if totalChanges == 0 { + _, _ = fmt.Fprintln(w, "\nNo changes detected.") + return + } + + _, _ = fmt.Fprintln(w, "\nSession Changes") + _, _ = fmt.Fprintln(w, strings.Repeat("─", 40)) + + // Print mount changes + for _, mc := range cs.MountChanges { + if len(mc.Changes) == 0 { + continue + } + // Determine label based on mount target + label := mountLabel(mc.Target) + _, _ = fmt.Fprintf(w, "\n%s (%s → %s):\n", label, mc.Source, mc.Target) + printChanges(w, mc.Changes) + } +} + +// mountLabel returns a human-friendly label based on the guest mount target +func mountLabel(target string) string { + switch { + case strings.HasPrefix(target, "/opt/toolchain"): + return "Toolchain" + case strings.HasPrefix(target, "/mnt/host-claude"): + return "Claude Config" + default: + return "Project" + } +} + +// printChanges prints individual file changes, summarizing if >maxDisplayChanges +func printChanges(w io.Writer, changes []Change) { + if len(changes) > maxDisplayChanges { + // Show top 5 of each type, then summary + created, modified, deleted := categorize(changes) + shown := 0 + for _, c := range created { + if shown >= 5 { + break + } + printChange(w, c) + shown++ + } + for _, c := range modified { + if shown >= 5 { + break + } + printChange(w, c) + shown++ + } + for _, c := range deleted { + if shown >= 5 { + break + } + printChange(w, c) + shown++ + } + _, _ = fmt.Fprintf(w, " (%d changes total: %d created, %d modified, %d deleted)\n", + len(changes), len(created), len(modified), len(deleted)) + return + } + for _, c := range changes { + printChange(w, c) + } +} + +// printChange prints a single change line +func printChange(w io.Writer, c Change) { + switch c.Type { + case "created": + _, _ = fmt.Fprintf(w, " + %-50s (%s)\n", c.Path, formatSize(c.NewSize)) + case "modified": + _, _ = fmt.Fprintf(w, " ~ %-50s (%s → %s)\n", c.Path, formatSize(c.OldSize), formatSize(c.NewSize)) + case "deleted": + _, _ = fmt.Fprintf(w, " - %s\n", c.Path) + } +} + +// categorize splits changes into created/modified/deleted slices +func categorize(changes []Change) (created, modified, deleted []Change) { + for _, c := range changes { + switch c.Type { + case "created": + created = append(created, c) + case "modified": + modified = append(modified, c) + case "deleted": + deleted = append(deleted, c) + } + } + return +} + +// formatSize returns a human-readable file size +func formatSize(bytes int64) string { + switch { + case bytes >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(1<<20)) + case bytes >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go new file mode 100644 index 0000000..4051ff1 --- /dev/null +++ b/internal/changeset/snapshot.go @@ -0,0 +1,285 @@ +package changeset + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// FileEntry records a single file's metadata at snapshot time. +type FileEntry struct { + Path string `json:"path"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` + Mode os.FileMode `json:"mode"` + IsDir bool `json:"is_dir"` + // For summarized directories (node_modules, etc): count of children + ChildCount int `json:"child_count,omitempty"` +} + +// Snapshot is a map of relative paths to FileEntry. +type Snapshot map[string]FileEntry + +// Take walks a directory and returns a Snapshot. +// - Uses filepath.WalkDir for efficiency +// - Skips .git directory contents (records .git dir entry itself only) +// - For node_modules or any dir with >500 direct children: records dir entry + child count, doesn't recurse +// - All paths are relative to root +func Take(root string) (Snapshot, error) { + snap := make(Snapshot) + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + + // Skip the root itself + if rel == "." { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + entry := FileEntry{ + Path: rel, + Size: info.Size(), + ModTime: info.ModTime(), + Mode: info.Mode(), + IsDir: d.IsDir(), + } + + // Handle .git: record dir entry, skip contents + if d.IsDir() && d.Name() == ".git" { + snap[rel] = entry + return filepath.SkipDir + } + + // For directories, check child count before deciding to recurse + if d.IsDir() { + children, err := os.ReadDir(path) + if err != nil { + return err + } + childCount := len(children) + entry.ChildCount = childCount + + // Summarize large dirs (node_modules or >500 direct children) + if d.Name() == "node_modules" || childCount > 500 { + snap[rel] = entry + return filepath.SkipDir + } + } + + snap[rel] = entry + return nil + }) + if err != nil { + return nil, err + } + + return snap, nil +} + +// Change represents a single file change. +type Change struct { + Path string `json:"path"` // relative to mount root + Type string `json:"type"` // "created", "modified", "deleted" + OldSize int64 `json:"old_size,omitempty"` + NewSize int64 `json:"new_size,omitempty"` +} + +// Diff compares two snapshots and returns changes. +// - Files in after but not before = "created" +// - Files in before but not after = "deleted" +// - Files in both but with different size or modtime = "modified" +func Diff(before, after Snapshot) []Change { + var changes []Change + + // Check for created and modified + for path, afterEntry := range after { + beforeEntry, exists := before[path] + if !exists { + changes = append(changes, Change{ + Path: path, + Type: "created", + NewSize: afterEntry.Size, + }) + continue + } + if beforeEntry.Size != afterEntry.Size || !beforeEntry.ModTime.Equal(afterEntry.ModTime) { + changes = append(changes, Change{ + Path: path, + Type: "modified", + OldSize: beforeEntry.Size, + NewSize: afterEntry.Size, + }) + } + } + + // Check for deleted + for path, beforeEntry := range before { + if _, exists := after[path]; !exists { + changes = append(changes, Change{ + Path: path, + Type: "deleted", + OldSize: beforeEntry.Size, + }) + } + } + + // Sort by path for deterministic output + sort.Slice(changes, func(i, j int) bool { + return changes[i].Path < changes[j].Path + }) + + return changes +} + +// MountChanges groups changes by mount source. +type MountChanges struct { + Source string `json:"source"` // host path + Target string `json:"target"` // guest path + Changes []Change `json:"changes"` +} + +// SessionChangeset is the complete changeset for a session. +type SessionChangeset struct { + SessionID string `json:"session_id"` + MountChanges []MountChanges `json:"mount_changes"` + GuestChanges []string `json:"guest_changes"` // lines from guest-changes.txt +} + +// Save persists a snapshot to JSON file. +func (s Snapshot) Save(path string) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// Load reads a snapshot from JSON file. +func Load(path string) (Snapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var snap Snapshot + if err := json.Unmarshal(data, &snap); err != nil { + return nil, err + } + return snap, nil +} + +// SaveChangeset saves a SessionChangeset to JSON. +func SaveChangeset(path string, cs *SessionChangeset) error { + data, err := json.MarshalIndent(cs, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// LoadChangeset loads a SessionChangeset from JSON. +func LoadChangeset(path string) (*SessionChangeset, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cs SessionChangeset + if err := json.Unmarshal(data, &cs); err != nil { + return nil, err + } + return &cs, nil +} + +// ParseGuestChanges reads guest-changes.txt and returns the lines. +// Returns empty slice and nil error if the file doesn't exist. +func ParseGuestChanges(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + defer func() { _ = f.Close() }() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lines = append(lines, line) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + if lines == nil { + return []string{}, nil + } + return lines, nil +} + +// defaultIgnorePrefixes are path prefixes for internal state that should not +// appear in user-facing change summaries. +var defaultIgnorePrefixes = []string{".git", ".omc", ".claude"} + +// matchesIgnorePrefix reports whether path starts with any default ignore prefix. +func matchesIgnorePrefix(path string) bool { + for _, prefix := range defaultIgnorePrefixes { + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + return false +} + +// FilterNoise removes directory entries and internal-state paths from a change list. +// Directory entries are redundant when child files are listed. +// Internal paths (.git, .omc, .claude) are not user code. +func FilterNoise(changes []Change, before, after Snapshot) []Change { + var filtered []Change + for _, c := range changes { + // Skip directories + if entry, ok := after[c.Path]; ok && entry.IsDir { + continue + } + if entry, ok := before[c.Path]; ok && entry.IsDir { + continue + } + // Skip noise paths + if matchesIgnorePrefix(c.Path) { + continue + } + filtered = append(filtered, c) + } + return filtered +} + +// FilterPaths removes internal-state paths from a change list (prefix-only filtering). +// Use this when snapshots are not available (e.g. loading saved changesets). +func FilterPaths(changes []Change) []Change { + var filtered []Change + for _, c := range changes { + if matchesIgnorePrefix(c.Path) { + continue + } + filtered = append(filtered, c) + } + return filtered +} diff --git a/internal/changeset/snapshot_test.go b/internal/changeset/snapshot_test.go new file mode 100644 index 0000000..946fdb9 --- /dev/null +++ b/internal/changeset/snapshot_test.go @@ -0,0 +1,241 @@ +package changeset + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTake_BasicFiles(t *testing.T) { + // Create temp dir with a few files, verify snapshot entries + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "file1.txt"), []byte("hello"), 0644) + _ = os.WriteFile(filepath.Join(dir, "file2.go"), []byte("package main"), 0644) + _ = os.MkdirAll(filepath.Join(dir, "subdir"), 0755) + _ = os.WriteFile(filepath.Join(dir, "subdir", "nested.txt"), []byte("nested"), 0644) + + snap, err := Take(dir) + require.NoError(t, err) + assert.Len(t, snap, 4) // file1, file2, subdir, subdir/nested + assert.Equal(t, int64(5), snap["file1.txt"].Size) + assert.True(t, snap["subdir"].IsDir) + assert.Equal(t, int64(6), snap["subdir/nested.txt"].Size) +} + +func TestTake_SkipsGitContents(t *testing.T) { + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + _ = os.MkdirAll(filepath.Join(gitDir, "objects"), 0755) + _ = os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main"), 0644) + _ = os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644) + + snap, err := Take(dir) + require.NoError(t, err) + // Should have .git dir entry but NOT its children + assert.Contains(t, snap, ".git") + assert.True(t, snap[".git"].IsDir) + assert.NotContains(t, snap, ".git/HEAD") + assert.NotContains(t, snap, ".git/objects") + assert.Contains(t, snap, "main.go") +} + +func TestTake_SummarizesNodeModules(t *testing.T) { + dir := t.TempDir() + nmDir := filepath.Join(dir, "node_modules") + _ = os.MkdirAll(nmDir, 0755) + // Create a few fake packages + for i := 0; i < 5; i++ { + _ = os.WriteFile(filepath.Join(nmDir, fmt.Sprintf("pkg%d", i)), []byte("x"), 0644) + } + + snap, err := Take(dir) + require.NoError(t, err) + assert.Contains(t, snap, "node_modules") + assert.True(t, snap["node_modules"].IsDir) + assert.Equal(t, 5, snap["node_modules"].ChildCount) + // Should NOT contain children + assert.NotContains(t, snap, "node_modules/pkg0") +} + +func TestTake_EmptyDir(t *testing.T) { + dir := t.TempDir() + snap, err := Take(dir) + require.NoError(t, err) + assert.Empty(t, snap) +} + +func TestDiff_Created(t *testing.T) { + before := Snapshot{} + after := Snapshot{ + "new.txt": FileEntry{Path: "new.txt", Size: 100}, + } + changes := Diff(before, after) + assert.Len(t, changes, 1) + assert.Equal(t, "created", changes[0].Type) + assert.Equal(t, "new.txt", changes[0].Path) + assert.Equal(t, int64(100), changes[0].NewSize) +} + +func TestDiff_Deleted(t *testing.T) { + before := Snapshot{ + "old.txt": FileEntry{Path: "old.txt", Size: 50}, + } + after := Snapshot{} + changes := Diff(before, after) + assert.Len(t, changes, 1) + assert.Equal(t, "deleted", changes[0].Type) + assert.Equal(t, int64(50), changes[0].OldSize) +} + +func TestDiff_Modified(t *testing.T) { + now := time.Now() + before := Snapshot{ + "file.txt": FileEntry{Path: "file.txt", Size: 100, ModTime: now}, + } + after := Snapshot{ + "file.txt": FileEntry{Path: "file.txt", Size: 200, ModTime: now.Add(time.Second)}, + } + changes := Diff(before, after) + assert.Len(t, changes, 1) + assert.Equal(t, "modified", changes[0].Type) + assert.Equal(t, int64(100), changes[0].OldSize) + assert.Equal(t, int64(200), changes[0].NewSize) +} + +func TestDiff_NoChanges(t *testing.T) { + now := time.Now() + snap := Snapshot{ + "file.txt": FileEntry{Path: "file.txt", Size: 100, ModTime: now}, + } + changes := Diff(snap, snap) + assert.Empty(t, changes) +} + +func TestDiff_SortedOutput(t *testing.T) { + before := Snapshot{} + after := Snapshot{ + "z.txt": FileEntry{Path: "z.txt", Size: 1}, + "a.txt": FileEntry{Path: "a.txt", Size: 2}, + "m.txt": FileEntry{Path: "m.txt", Size: 3}, + } + changes := Diff(before, after) + assert.Len(t, changes, 3) + assert.Equal(t, "a.txt", changes[0].Path) + assert.Equal(t, "m.txt", changes[1].Path) + assert.Equal(t, "z.txt", changes[2].Path) +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "snap.json") + + now := time.Now().Truncate(time.Millisecond) // JSON loses sub-ms precision + original := Snapshot{ + "file.txt": FileEntry{Path: "file.txt", Size: 42, ModTime: now, Mode: 0644}, + } + require.NoError(t, original.Save(path)) + + loaded, err := Load(path) + require.NoError(t, err) + assert.Equal(t, original["file.txt"].Size, loaded["file.txt"].Size) + assert.Equal(t, original["file.txt"].Path, loaded["file.txt"].Path) +} + +func TestParseGuestChanges_MissingFile(t *testing.T) { + lines, err := ParseGuestChanges("/nonexistent/path") + require.NoError(t, err) + assert.Empty(t, lines) +} + +func TestParseGuestChanges_WithContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "guest-changes.txt") + content := "/etc/resolv.conf\n/home/claude/.cache/pip/something\n\n/usr/bin/newpkg\n" + _ = os.WriteFile(path, []byte(content), 0644) + + lines, err := ParseGuestChanges(path) + require.NoError(t, err) + assert.Len(t, lines, 3) + assert.Equal(t, "/etc/resolv.conf", lines[0]) +} + +func TestFilterNoise_RemovesDirectories(t *testing.T) { + before := Snapshot{} + after := Snapshot{ + "internal/cmd": FileEntry{Path: "internal/cmd", IsDir: true}, + "internal/cmd/start.go": FileEntry{Path: "internal/cmd/start.go", Size: 100}, + } + changes := []Change{ + {Path: "internal/cmd", Type: "modified"}, + {Path: "internal/cmd/start.go", Type: "modified", NewSize: 100}, + } + filtered := FilterNoise(changes, before, after) + assert.Len(t, filtered, 1) + assert.Equal(t, "internal/cmd/start.go", filtered[0].Path) +} + +func TestFilterNoise_RemovesIgnoredPrefixes(t *testing.T) { + before := Snapshot{} + after := Snapshot{ + ".git/HEAD": FileEntry{Path: ".git/HEAD", Size: 40}, + ".omc/state.json": FileEntry{Path: ".omc/state.json", Size: 200}, + ".claude/settings.json": FileEntry{Path: ".claude/settings.json", Size: 50}, + "main.go": FileEntry{Path: "main.go", Size: 300}, + } + changes := []Change{ + {Path: ".git/HEAD", Type: "modified"}, + {Path: ".omc/state.json", Type: "created"}, + {Path: ".claude/settings.json", Type: "modified"}, + {Path: "main.go", Type: "modified"}, + } + filtered := FilterNoise(changes, before, after) + assert.Len(t, filtered, 1) + assert.Equal(t, "main.go", filtered[0].Path) +} + +func TestFilterNoise_KeepsRegularFiles(t *testing.T) { + before := Snapshot{ + "old.go": FileEntry{Path: "old.go", Size: 50}, + } + after := Snapshot{ + "old.go": FileEntry{Path: "old.go", Size: 100}, + "new.go": FileEntry{Path: "new.go", Size: 200}, + } + changes := Diff(before, after) + filtered := FilterNoise(changes, before, after) + assert.Len(t, filtered, 2) +} + +func TestFilterPaths_RemovesIgnoredPrefixes(t *testing.T) { + changes := []Change{ + {Path: ".git/HEAD", Type: "modified"}, + {Path: ".omc/notepad.md", Type: "created"}, + {Path: "src/main.go", Type: "modified"}, + } + filtered := FilterPaths(changes) + assert.Len(t, filtered, 1) + assert.Equal(t, "src/main.go", filtered[0].Path) +} + +func TestFilterNoise_EmptyInput(t *testing.T) { + filtered := FilterNoise(nil, Snapshot{}, Snapshot{}) + assert.Nil(t, filtered) +} + +func TestFilterPaths_ExactPrefixMatch(t *testing.T) { + // ".github" should NOT be filtered (doesn't match ".git" prefix) + changes := []Change{ + {Path: ".github/workflows/ci.yml", Type: "created"}, + {Path: ".gitignore", Type: "modified"}, + {Path: ".git/HEAD", Type: "modified"}, + } + filtered := FilterPaths(changes) + assert.Len(t, filtered, 2) + assert.Equal(t, ".github/workflows/ci.yml", filtered[0].Path) + assert.Equal(t, ".gitignore", filtered[1].Path) +} diff --git a/internal/cmd/diff.go b/internal/cmd/diff.go new file mode 100644 index 0000000..d2e42e4 --- /dev/null +++ b/internal/cmd/diff.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/faize-ai/faize/internal/changeset" + "github.com/faize-ai/faize/internal/session" + "github.com/spf13/cobra" +) + +var diffJSON bool + +var diffCmd = &cobra.Command{ + Use: "diff [session-id]", + Short: "Show changes from a session", + Long: `Show file changes made during a faize session. + +If no session-id is given, shows changes from the most recent session. + +Examples: + faize diff + faize diff abc123 + faize diff --json`, + RunE: runDiff, +} + +func init() { + diffCmd.Flags().BoolVar(&diffJSON, "json", false, "output in JSON format") + rootCmd.AddCommand(diffCmd) +} + +func runDiff(cmd *cobra.Command, args []string) error { + store, err := session.NewStore() + if err != nil { + return fmt.Errorf("failed to open session store: %w", err) + } + + var sessionID string + if len(args) > 0 { + sessionID = args[0] + } else { + // Find most recent session + sessionID, err = findMostRecentSession(store) + if err != nil { + return err + } + } + + // Look for changeset.json in session's bootstrap dir + bootstrapDir := filepath.Join(store.Dir(), sessionID, "bootstrap") + changesetPath := filepath.Join(bootstrapDir, "changeset.json") + + cs, err := changeset.LoadChangeset(changesetPath) + if err != nil { + return fmt.Errorf("no changeset found for session %s: %w", sessionID, err) + } + + if diffJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(cs) + } + + // Filter noise paths from older saved changesets + for i := range cs.MountChanges { + cs.MountChanges[i].Changes = changeset.FilterPaths(cs.MountChanges[i].Changes) + } + changeset.PrintSummary(os.Stdout, cs) + return nil +} + +// findMostRecentSession returns the ID of the most recently started session. +func findMostRecentSession(store *session.Store) (string, error) { + sessions, err := store.List() + if err != nil { + return "", fmt.Errorf("failed to list sessions: %w", err) + } + if len(sessions) == 0 { + return "", fmt.Errorf("no sessions found") + } + + // Sort by StartedAt descending + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].StartedAt.After(sessions[j].StartedAt) + }) + + return sessions[0].ID, nil +} diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 19c4d49..c97b171 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/faize-ai/faize/internal/changeset" "github.com/faize-ai/faize/internal/config" "github.com/faize-ai/faize/internal/git" "github.com/faize-ai/faize/internal/mount" @@ -23,7 +24,8 @@ var ( startTimeout string startPersistCreds bool startNoGitContext bool - startClaude bool + startClaude bool + startNoDiff bool ) var startCmd = &cobra.Command{ @@ -52,6 +54,7 @@ func init() { startCmd.Flags().BoolVar(&startPersistCreds, "persist-credentials", false, "persist Claude credentials across sessions") startCmd.Flags().BoolVar(&startNoGitContext, "no-git-context", false, "disable automatic .git directory mounting from git root") startCmd.Flags().BoolVar(&startClaude, "claude", true, "use Claude Code mode") + startCmd.Flags().BoolVar(&startNoDiff, "no-diff", false, "disable change tracking and summary") rootCmd.AddCommand(startCmd) } @@ -266,6 +269,35 @@ func runStart(cmd *cobra.Command, args []string) error { } Debug("VM started successfully") + // Take pre-snapshots of rw mounts for change tracking + type mountSnapshot struct { + source string + target string + tag string + snap changeset.Snapshot + } + var preSnapshots []mountSnapshot + showDiff := cfg.Claude.ShouldShowDiff() && !startNoDiff + if showDiff { + for _, m := range parsedMounts { + if m.ReadOnly { + continue + } + Debug("Taking pre-snapshot of %s", m.Source) + snap, err := changeset.Take(m.Source) + if err != nil { + Debug("Failed to snapshot %s: %v", m.Source, err) + continue + } + preSnapshots = append(preSnapshots, mountSnapshot{ + source: m.Source, + target: m.Target, + tag: m.Tag, + snap: snap, + }) + } + } + // Ensure session is stopped when we exit (detach, VM stop, error, signal) defer func() { fmt.Printf("\nStopping session %s...\n", sess.ID) @@ -283,5 +315,49 @@ func runStart(cmd *cobra.Command, args []string) error { if err := manager.Attach(sess.ID); err != nil && !errors.Is(err, vm.ErrUserDetach) { return fmt.Errorf("console error: %w", err) } + + // Post-session change tracking + if showDiff && len(preSnapshots) > 0 { + var mountChanges []changeset.MountChanges + for _, pre := range preSnapshots { + Debug("Taking post-snapshot of %s", pre.source) + postSnap, err := changeset.Take(pre.source) + if err != nil { + Debug("Failed to post-snapshot %s: %v", pre.source, err) + continue + } + changes := changeset.Diff(pre.snap, postSnap) + changes = changeset.FilterNoise(changes, pre.snap, postSnap) + if len(changes) > 0 { + mountChanges = append(mountChanges, changeset.MountChanges{ + Source: pre.source, + Target: pre.target, + Changes: changes, + }) + } + } + + // Read guest-side changes from bootstrap dir + bootstrapDir := filepath.Join(home, ".faize", "sessions", sess.ID, "bootstrap") + guestChanges, _ := changeset.ParseGuestChanges(filepath.Join(bootstrapDir, "guest-changes.txt")) + + cs := &changeset.SessionChangeset{ + SessionID: sess.ID, + MountChanges: mountChanges, + GuestChanges: guestChanges, + } + + // Display summary + changeset.PrintSummary(os.Stdout, cs) + + // Save for later viewing with `faize diff` + changesetPath := filepath.Join(bootstrapDir, "changeset.json") + if err := os.MkdirAll(bootstrapDir, 0755); err == nil { + if saveErr := changeset.SaveChangeset(changesetPath, cs); saveErr != nil { + Debug("Failed to save changeset: %v", saveErr) + } + } + } + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 74381b4..058c6bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,7 @@ type Claude struct { PersistCredentials *bool `yaml:"persist_credentials"` ExtraDeps []string `yaml:"extra_deps"` GitContext *bool `yaml:"git_context"` + ShowDiff *bool `yaml:"show_diff"` } // ShouldPersistCredentials returns whether credential persistence is enabled. @@ -62,6 +63,15 @@ func (c *Claude) ShouldMountGitContext() bool { return *c.GitContext } +// ShouldShowDiff returns whether session change tracking and diff display is enabled. +// Defaults to true when not explicitly set. +func (c *Claude) ShouldShowDiff() bool { + if c.ShowDiff == nil { + return true + } + return *c.ShowDiff +} + // Load loads the configuration from ~/.faize/config.yaml or returns defaults func Load() (*Config, error) { home, err := homedir.Dir() diff --git a/internal/guest/init.go b/internal/guest/init.go index 02ec2cf..0a4f05a 100644 --- a/internal/guest/init.go +++ b/internal/guest/init.go @@ -130,6 +130,18 @@ func GenerateClaudeInitScript(mounts []session.VMMount, projectDir string, polic sb.WriteString(" fi\n") } + sb.WriteString(" # Record files modified during session (rootfs overlay changes)\n") + sb.WriteString(" {\n") + sb.WriteString(" find / -newer /mnt/bootstrap/init.sh \\\n") + sb.WriteString(" -not -path '/proc/*' \\\n") + sb.WriteString(" -not -path '/sys/*' \\\n") + sb.WriteString(" -not -path '/dev/*' \\\n") + sb.WriteString(" -not -path '/mnt/*' \\\n") + sb.WriteString(" -not -path '/tmp/*' \\\n") + sb.WriteString(" -not -path '/run/*' \\\n") + sb.WriteString(" 2>/dev/null || true\n") + sb.WriteString(" } > /mnt/bootstrap/guest-changes.txt 2>/dev/null\n") + sb.WriteString(" # Sync filesystems\n") sb.WriteString(" sync\n") sb.WriteString(" # Power off\n")