diff --git a/internal/session/stale.go b/internal/session/stale.go new file mode 100644 index 0000000..260ad1f --- /dev/null +++ b/internal/session/stale.go @@ -0,0 +1,73 @@ +package session + +import ( + "fmt" + "sort" + "strings" +) + +// DetectExternalChanges compares the current workspace state against the +// EndManifest recorded in .ctask/last-session-summary.json. +// +// Returns (nil, nil) if no summary exists (pre-v0.4 workspace or first +// session ever) — callers should treat this as "silently skip the warning". +// +// Returns a non-nil diff with empty slices when nothing changed outside a +// ctask session. +func DetectExternalChanges(wsDir string) (*ManifestDiff, error) { + summary, err := ReadSummary(SummaryPath(wsDir)) + if err != nil { + return nil, err + } + if summary == nil { + return nil, nil + } + + previous := &Manifest{ + CapturedAt: summary.EndedAt, + Files: append([]FileEntry{}, summary.EndManifest...), + } + current, err := CaptureManifest(wsDir) + if err != nil { + return nil, err + } + return DiffManifests(previous, current), nil +} + +// FormatStaleWarning renders the Layer-3 warning shown when the workspace +// was modified outside a ctask session since the last session ended. +func FormatStaleWarning(summary *SessionSummary, diff *ManifestDiff) string { + var b strings.Builder + b.WriteString("[ctask] Workspace modified since last session ended:\n\n") + fmt.Fprintf(&b, " Last session: %s (%s, %s)\n\n", + summary.EndedAt.Local().Format("2006-01-02 15:04"), + summary.Hostname, summary.Agent) + b.WriteString(" Modified since then:\n") + + lines := append([]string{}, diff.Modified...) + sort.Strings(lines) + for _, f := range lines { + fmt.Fprintf(&b, " %s (modified)\n", f) + } + added := append([]string{}, diff.Added...) + sort.Strings(added) + for _, f := range added { + fmt.Fprintf(&b, " %s (new file)\n", f) + } + deleted := append([]string{}, diff.Deleted...) + sort.Strings(deleted) + for _, f := range deleted { + fmt.Fprintf(&b, " %s (deleted)\n", f) + } + b.WriteString("\n These changes were not made during a ctask session.\n") + b.WriteString(" Review before continuing? [Y/n] ") + return b.String() +} + +// HasChanges returns true if diff reports any added/modified/deleted entries. +func HasChanges(d *ManifestDiff) bool { + if d == nil { + return false + } + return len(d.Added) > 0 || len(d.Modified) > 0 || len(d.Deleted) > 0 +} diff --git a/internal/session/stale_test.go b/internal/session/stale_test.go new file mode 100644 index 0000000..1b068eb --- /dev/null +++ b/internal/session/stale_test.go @@ -0,0 +1,105 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDetectExternalChangesNoSummarySilent(t *testing.T) { + wsDir := t.TempDir() + + diff, err := DetectExternalChanges(wsDir) + if err != nil { + t.Fatalf("DetectExternalChanges: %v", err) + } + if diff != nil { + t.Errorf("expected nil diff when no summary exists, got %+v", diff) + } +} + +func TestDetectExternalChangesNoopWhenUnchanged(t *testing.T) { + wsDir := t.TempDir() + + notes := filepath.Join(wsDir, "notes.md") + if err := os.WriteFile(notes, []byte("hello"), 0644); err != nil { + t.Fatalf("write notes: %v", err) + } + info, _ := os.Stat(notes) + + summary := &SessionSummary{ + EndManifest: []FileEntry{ + {Path: "notes.md", Size: info.Size(), Mtime: info.ModTime().UTC().Truncate(time.Second)}, + }, + } + WriteSummary(SummaryPath(wsDir), summary) + + diff, err := DetectExternalChanges(wsDir) + if err != nil { + t.Fatalf("DetectExternalChanges: %v", err) + } + if diff == nil { + t.Fatal("expected non-nil diff") + } + if len(diff.Added) != 0 || len(diff.Modified) != 0 || len(diff.Deleted) != 0 { + t.Errorf("expected empty diff, got %+v", diff) + } +} + +func TestDetectExternalChangesDetectsModifications(t *testing.T) { + wsDir := t.TempDir() + + oldTime := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + summary := &SessionSummary{ + EndManifest: []FileEntry{ + {Path: "notes.md", Size: 5, Mtime: oldTime}, + }, + } + WriteSummary(SummaryPath(wsDir), summary) + + os.WriteFile(filepath.Join(wsDir, "notes.md"), []byte("hello world"), 0644) + os.MkdirAll(filepath.Join(wsDir, "output"), 0755) + os.WriteFile(filepath.Join(wsDir, "output", "report.md"), []byte("x"), 0644) + + diff, err := DetectExternalChanges(wsDir) + if err != nil { + t.Fatalf("DetectExternalChanges: %v", err) + } + if diff == nil { + t.Fatal("expected non-nil diff") + } + if len(diff.Added) != 1 || diff.Added[0] != "output/report.md" { + t.Errorf("Added: got %v, want [output/report.md]", diff.Added) + } + if len(diff.Modified) != 1 || diff.Modified[0] != "notes.md" { + t.Errorf("Modified: got %v, want [notes.md]", diff.Modified) + } +} + +func TestFormatStaleWarning(t *testing.T) { + summary := &SessionSummary{ + EndedAt: time.Date(2026, 4, 21, 15, 45, 0, 0, time.UTC), + Hostname: "warren-desktop", + Agent: "claude", + } + diff := &ManifestDiff{ + Added: []string{"output/report.md"}, + Modified: []string{"notes.md"}, + } + got := FormatStaleWarning(summary, diff) + for _, want := range []string{ + "[ctask] Workspace modified since last session ended", + "Last session:", + "warren-desktop", + "claude", + "notes.md", + "output/report.md", + "Review before continuing? [Y/n]", + } { + if !strings.Contains(got, want) { + t.Errorf("FormatStaleWarning missing %q in:\n%s", want, got) + } + } +}