From 881fb0a81c243c402beb983b389d3241345d4fb7 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 23 Feb 2026 15:56:34 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8feat(config):=20add=20show=5Fdiff?= =?UTF-8?q?=20option=20and=20session=20change=20tracking=20in=20start=20co?= =?UTF-8?q?mmand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 143 +++++++++++++++++ internal/changeset/snapshot.go | 236 ++++++++++++++++++++++++++++ internal/changeset/snapshot_test.go | 165 +++++++++++++++++++ internal/cmd/diff.go | 88 +++++++++++ internal/cmd/start.go | 77 ++++++++- internal/config/config.go | 10 ++ internal/guest/init.go | 12 ++ 7 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 internal/changeset/display.go create mode 100644 internal/changeset/snapshot.go create mode 100644 internal/changeset/snapshot_test.go create mode 100644 internal/cmd/diff.go diff --git a/internal/changeset/display.go b/internal/changeset/display.go new file mode 100644 index 0000000..b2ca632 --- /dev/null +++ b/internal/changeset/display.go @@ -0,0 +1,143 @@ +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) + } + totalChanges += len(cs.GuestChanges) + + 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) + } + + // Print guest changes + if len(cs.GuestChanges) > 0 { + fmt.Fprintf(w, "\nSystem (rootfs overlay):\n") + if len(cs.GuestChanges) > maxDisplayChanges { + for _, path := range cs.GuestChanges[:5] { + fmt.Fprintf(w, " ~ %s\n", path) + } + fmt.Fprintf(w, " (%d files total)\n", len(cs.GuestChanges)) + } else { + for _, path := range cs.GuestChanges { + fmt.Fprintf(w, " ~ %s\n", path) + } + } + } +} + +// 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..6ddc75e --- /dev/null +++ b/internal/changeset/snapshot.go @@ -0,0 +1,236 @@ +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 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 +} diff --git a/internal/changeset/snapshot_test.go b/internal/changeset/snapshot_test.go new file mode 100644 index 0000000..1115e86 --- /dev/null +++ b/internal/changeset/snapshot_test.go @@ -0,0 +1,165 @@ +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]) +} diff --git a/internal/cmd/diff.go b/internal/cmd/diff.go new file mode 100644 index 0000000..93aaa31 --- /dev/null +++ b/internal/cmd/diff.go @@ -0,0 +1,88 @@ +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) + } + + 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..58b8f6f 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,48 @@ 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) + 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") From 6e651ae7d9470a887965b421f41ebcb896d3c515 Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Mon, 23 Feb 2026 16:35:53 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20feat:=20silence=20unchecked=20e?= =?UTF-8?q?rrors=20when=20printing=20and=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 24 ++++++++++++------------ internal/changeset/snapshot.go | 2 +- internal/changeset/snapshot_test.go | 20 ++++++++++---------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/internal/changeset/display.go b/internal/changeset/display.go index b2ca632..acbb147 100644 --- a/internal/changeset/display.go +++ b/internal/changeset/display.go @@ -21,12 +21,12 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { totalChanges += len(cs.GuestChanges) if totalChanges == 0 { - fmt.Fprintln(w, "\nNo changes detected.") + _, _ = fmt.Fprintln(w, "\nNo changes detected.") return } - fmt.Fprintln(w, "\nSession Changes") - fmt.Fprintln(w, strings.Repeat("─", 40)) + _, _ = fmt.Fprintln(w, "\nSession Changes") + _, _ = fmt.Fprintln(w, strings.Repeat("─", 40)) // Print mount changes for _, mc := range cs.MountChanges { @@ -35,21 +35,21 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { } // Determine label based on mount target label := mountLabel(mc.Target) - fmt.Fprintf(w, "\n%s (%s → %s):\n", label, mc.Source, mc.Target) + _, _ = fmt.Fprintf(w, "\n%s (%s → %s):\n", label, mc.Source, mc.Target) printChanges(w, mc.Changes) } // Print guest changes if len(cs.GuestChanges) > 0 { - fmt.Fprintf(w, "\nSystem (rootfs overlay):\n") + _, _ = fmt.Fprintf(w, "\nSystem (rootfs overlay):\n") if len(cs.GuestChanges) > maxDisplayChanges { for _, path := range cs.GuestChanges[:5] { - fmt.Fprintf(w, " ~ %s\n", path) + _, _ = fmt.Fprintf(w, " ~ %s\n", path) } - fmt.Fprintf(w, " (%d files total)\n", len(cs.GuestChanges)) + _, _ = fmt.Fprintf(w, " (%d files total)\n", len(cs.GuestChanges)) } else { for _, path := range cs.GuestChanges { - fmt.Fprintf(w, " ~ %s\n", path) + _, _ = fmt.Fprintf(w, " ~ %s\n", path) } } } @@ -94,7 +94,7 @@ func printChanges(w io.Writer, changes []Change) { printChange(w, c) shown++ } - fmt.Fprintf(w, " (%d changes total: %d created, %d modified, %d deleted)\n", + _, _ = fmt.Fprintf(w, " (%d changes total: %d created, %d modified, %d deleted)\n", len(changes), len(created), len(modified), len(deleted)) return } @@ -107,11 +107,11 @@ func printChanges(w io.Writer, changes []Change) { func printChange(w io.Writer, c Change) { switch c.Type { case "created": - fmt.Fprintf(w, " + %-50s (%s)\n", c.Path, formatSize(c.NewSize)) + _, _ = 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)) + _, _ = fmt.Fprintf(w, " ~ %-50s (%s → %s)\n", c.Path, formatSize(c.OldSize), formatSize(c.NewSize)) case "deleted": - fmt.Fprintf(w, " - %s\n", c.Path) + _, _ = fmt.Fprintf(w, " - %s\n", c.Path) } } diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go index 6ddc75e..5dde1cc 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -215,7 +215,7 @@ func ParseGuestChanges(path string) ([]string, error) { } return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() var lines []string scanner := bufio.NewScanner(f) diff --git a/internal/changeset/snapshot_test.go b/internal/changeset/snapshot_test.go index 1115e86..6c88794 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -14,10 +14,10 @@ import ( 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) + _ = 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) @@ -30,9 +30,9 @@ func TestTake_BasicFiles(t *testing.T) { 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) + _ = 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) @@ -47,10 +47,10 @@ func TestTake_SkipsGitContents(t *testing.T) { func TestTake_SummarizesNodeModules(t *testing.T) { dir := t.TempDir() nmDir := filepath.Join(dir, "node_modules") - os.MkdirAll(nmDir, 0755) + _ = 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) + _ = os.WriteFile(filepath.Join(nmDir, fmt.Sprintf("pkg%d", i)), []byte("x"), 0644) } snap, err := Take(dir) @@ -156,7 +156,7 @@ 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) + _ = os.WriteFile(path, []byte(content), 0644) lines, err := ParseGuestChanges(path) require.NoError(t, err) From ae3dc2a8e018a51665e77f1ecb860d8b7efc366c Mon Sep 17 00:00:00 2001 From: Miguel Diaz Date: Tue, 24 Feb 2026 00:51:18 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20feat:=20filter=20internal/dir?= =?UTF-8?q?=20noise=20from=20changesets=20and=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/changeset/display.go | 16 ------ internal/changeset/snapshot.go | 49 +++++++++++++++++++ internal/changeset/snapshot_test.go | 76 +++++++++++++++++++++++++++++ internal/cmd/diff.go | 4 ++ internal/cmd/start.go | 1 + 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/internal/changeset/display.go b/internal/changeset/display.go index acbb147..93c5de5 100644 --- a/internal/changeset/display.go +++ b/internal/changeset/display.go @@ -18,7 +18,6 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { for _, mc := range cs.MountChanges { totalChanges += len(mc.Changes) } - totalChanges += len(cs.GuestChanges) if totalChanges == 0 { _, _ = fmt.Fprintln(w, "\nNo changes detected.") @@ -38,21 +37,6 @@ func PrintSummary(w io.Writer, cs *SessionChangeset) { _, _ = fmt.Fprintf(w, "\n%s (%s → %s):\n", label, mc.Source, mc.Target) printChanges(w, mc.Changes) } - - // Print guest changes - if len(cs.GuestChanges) > 0 { - _, _ = fmt.Fprintf(w, "\nSystem (rootfs overlay):\n") - if len(cs.GuestChanges) > maxDisplayChanges { - for _, path := range cs.GuestChanges[:5] { - _, _ = fmt.Fprintf(w, " ~ %s\n", path) - } - _, _ = fmt.Fprintf(w, " (%d files total)\n", len(cs.GuestChanges)) - } else { - for _, path := range cs.GuestChanges { - _, _ = fmt.Fprintf(w, " ~ %s\n", path) - } - } - } } // mountLabel returns a human-friendly label based on the guest mount target diff --git a/internal/changeset/snapshot.go b/internal/changeset/snapshot.go index 5dde1cc..4051ff1 100644 --- a/internal/changeset/snapshot.go +++ b/internal/changeset/snapshot.go @@ -234,3 +234,52 @@ func ParseGuestChanges(path string) ([]string, error) { } 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 index 6c88794..946fdb9 100644 --- a/internal/changeset/snapshot_test.go +++ b/internal/changeset/snapshot_test.go @@ -163,3 +163,79 @@ func TestParseGuestChanges_WithContent(t *testing.T) { 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 index 93aaa31..d2e42e4 100644 --- a/internal/cmd/diff.go +++ b/internal/cmd/diff.go @@ -65,6 +65,10 @@ func runDiff(cmd *cobra.Command, args []string) error { 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 } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 58b8f6f..c97b171 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -327,6 +327,7 @@ func runStart(cmd *cobra.Command, args []string) error { 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,