69c487cf79
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>
172 lines
3.9 KiB
Go
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
|
|
}
|