Files
typebasedio 69c487cf79 feat: v0.2 tests for manifest capture, diff, ignore rules, and session log
11 new tests covering manifest capture/exclusion, roundtrip, diff logic, ignore rules (task.yaml, sessions.log, .ctask/), notes updated detection, session log formatting, append-only behavior, short session detection. Fix cross-platform ignore rule for logs/sessions.log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:01:31 -04:00

172 lines
3.9 KiB
Go

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
}