package session import ( "encoding/json" "os" "path/filepath" "strings" "time" ) // FileEntry represents a single file in the workspace manifest. type FileEntry struct { Path string `json:"path"` Size int64 `json:"size"` Mtime time.Time `json:"mtime"` } // Manifest represents a point-in-time snapshot of workspace files. type Manifest struct { CapturedAt time.Time `json:"captured_at"` Files []FileEntry `json:"files"` } // ignoredPaths returns true for paths that should be excluded from manifest diffing. func ignoredPath(relPath string) bool { // Ignore .ctask/ directory contents if strings.HasPrefix(relPath, ".ctask/") || strings.HasPrefix(relPath, ".ctask\\") { return true } // Ignore task.yaml (ctask updates this itself) if relPath == "task.yaml" { return true } // Ignore sessions.log (ctask's own logging) if relPath == "logs/sessions.log" || relPath == "logs\\sessions.log" { return true } return false } // CaptureManifest walks the workspace directory and captures file metadata. func CaptureManifest(wsDir string) (*Manifest, error) { m := &Manifest{ CapturedAt: time.Now().UTC().Truncate(time.Second), } err := filepath.Walk(wsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip files we can't stat } if info.IsDir() { // Skip .ctask directory entirely rel, _ := filepath.Rel(wsDir, path) if rel == ".ctask" { return filepath.SkipDir } return nil } rel, err := filepath.Rel(wsDir, path) if err != nil { return nil } // Normalize to forward slashes for consistency rel = filepath.ToSlash(rel) m.Files = append(m.Files, FileEntry{ Path: rel, Size: info.Size(), Mtime: info.ModTime().UTC().Truncate(time.Second), }) return nil }) if err != nil { return nil, err } return m, nil } // WriteManifest writes a manifest to a JSON file. func WriteManifest(path string, m *Manifest) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := json.MarshalIndent(m, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } // ReadManifest reads a manifest from a JSON file. func ReadManifest(path string) (*Manifest, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, err } return &m, nil } // ManifestDiff represents the differences between two manifests. type ManifestDiff struct { Added []string // files only in end manifest Modified []string // files in both, but size or mtime differ Deleted []string // files only in start manifest } // DiffManifests compares start and end manifests, applying ignore rules. func DiffManifests(start, end *Manifest) *ManifestDiff { diff := &ManifestDiff{} startMap := make(map[string]FileEntry) for _, f := range start.Files { if !ignoredPath(f.Path) { startMap[f.Path] = f } } endMap := make(map[string]FileEntry) for _, f := range end.Files { if !ignoredPath(f.Path) { endMap[f.Path] = f } } // Added: in end but not start for path := range endMap { if _, ok := startMap[path]; !ok { diff.Added = append(diff.Added, path) } } // Modified: in both but different size or mtime for path, endEntry := range endMap { if startEntry, ok := startMap[path]; ok { if endEntry.Size != startEntry.Size || !endEntry.Mtime.Equal(startEntry.Mtime) { diff.Modified = append(diff.Modified, path) } } } // Deleted: in start but not end for path := range startMap { if _, ok := endMap[path]; !ok { diff.Deleted = append(diff.Deleted, path) } } return diff } // NotesUpdated checks if notes.md was modified between manifests. func NotesUpdated(diff *ManifestDiff) bool { for _, f := range diff.Modified { if f == "notes.md" { return true } } for _, f := range diff.Added { if f == "notes.md" { return true } } return false }