mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix: warn about untracked scratch files left behind after a run Fixes #551. Headless exec runs could leave behind debug/scratch files (e.g. _fix_test.py, _debug.py) created by write_file during iteration, which would then be silently swept up by a later git add -A. - FileTracker now records brand-new files (RecordCreated/CreatedFiles), distinct from the existing version-baseline tracking used for conflict detection. - write_file records a path as created only on first write, not on overwrite of an existing file. - After a headless run completes, exec.go checks git status for any created file still present and untracked, and surfaces a warning via the existing writer.warning() (works across text/-o json/stream-json). No files are deleted automatically — a wrong guess at 'scratch' could destroy real work, so this only surfaces what git already knows. Co-authored-by: Copilot App <[email protected]> * fix: address scratch warning review comments * test: normalize created file tracker paths * fix: track apply_patch-created scratch files * fix: address scratch warning review feedback * fix: tighten scratch warning lifecycle --------- Co-authored-by: Copilot App <[email protected]>
156 lines
5.2 KiB
Go
156 lines
5.2 KiB
Go
package tools
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrFileChangedOnDisk is returned by CheckConflict when a file's current
|
|
// on-disk content no longer matches what a tool last read or wrote in this
|
|
// session — i.e. it was modified outside Zero. The write tools surface it with
|
|
// guidance to re-read before retrying, so a stale overwrite cannot silently
|
|
// clobber an external edit.
|
|
var ErrFileChangedOnDisk = errors.New("the file changed on disk since you last read it")
|
|
|
|
// FileVersion is what a tool last observed for a file: an authoritative content
|
|
// hash plus cheap stat metadata kept for diagnostics.
|
|
type FileVersion struct {
|
|
Hash string
|
|
Size int64
|
|
MTime time.Time
|
|
}
|
|
|
|
// FileTracker records, per absolute path, the version of each file a tool read
|
|
// or wrote within a single session. A later write compares the current on-disk
|
|
// content against this baseline to detect a modification made outside Zero.
|
|
//
|
|
// Construct with NewFileTracker. A nil *FileTracker is a valid no-op — every
|
|
// method tolerates it — so a caller that has not wired the feature (tests, MCP)
|
|
// needs no nil guards.
|
|
type FileTracker struct {
|
|
mu sync.Mutex
|
|
versions map[string]FileVersion
|
|
// created preserves the order in which brand-new files (paths that did not
|
|
// exist before a write tool created them) were first written this session.
|
|
// A map would lose that order and complicates de-duplication, so a slice
|
|
// plus a seen-set is used instead.
|
|
created []string
|
|
createdSeen map[string]bool
|
|
}
|
|
|
|
func NewFileTracker() *FileTracker {
|
|
return &FileTracker{versions: make(map[string]FileVersion)}
|
|
}
|
|
|
|
// Record stores the version of absPath given its content and optional stat info.
|
|
func (tracker *FileTracker) Record(absPath string, content []byte, info os.FileInfo) {
|
|
tracker.RecordHash(absPath, HashContent(content), info)
|
|
}
|
|
|
|
// RecordHash stores the version of absPath when the caller already computed
|
|
// the same HashContent-compatible SHA-256 while reading the file.
|
|
func (tracker *FileTracker) RecordHash(absPath string, hash string, info os.FileInfo) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
version := FileVersion{Hash: hash}
|
|
if info != nil {
|
|
version.Size = info.Size()
|
|
version.MTime = info.ModTime()
|
|
}
|
|
tracker.mu.Lock()
|
|
tracker.versions[absPath] = version
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// Version returns the recorded version for absPath and whether one exists.
|
|
func (tracker *FileTracker) Version(absPath string) (FileVersion, bool) {
|
|
if tracker == nil {
|
|
return FileVersion{}, false
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
version, ok := tracker.versions[absPath]
|
|
return version, ok
|
|
}
|
|
|
|
// RecordCreated notes that absPath is a brand-new file a tool just created in
|
|
// this session (as opposed to an overwrite of something that already existed).
|
|
func (tracker *FileTracker) RecordCreated(absPath string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
if tracker.createdSeen == nil {
|
|
tracker.createdSeen = make(map[string]bool)
|
|
}
|
|
if tracker.createdSeen[absPath] {
|
|
return
|
|
}
|
|
tracker.createdSeen[absPath] = true
|
|
tracker.created = append(tracker.created, absPath)
|
|
}
|
|
|
|
// CreatedFiles returns the absolute paths of every brand-new file recorded via
|
|
// RecordCreated during this session, in first-created order. The caller owns
|
|
// the returned slice.
|
|
func (tracker *FileTracker) CreatedFiles() []string {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
out := make([]string, len(tracker.created))
|
|
copy(out, tracker.created)
|
|
return out
|
|
}
|
|
|
|
// Forget drops any recorded version for absPath, so the next read re-establishes
|
|
// the baseline. Used after a tool rewrites a file by a path other than its own
|
|
// (e.g. apply_patch) where the new content is not cheaply available to Record.
|
|
func (tracker *FileTracker) Forget(absPath string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
delete(tracker.versions, absPath)
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// CheckConflict reports ErrFileChangedOnDisk when absPath has a recorded baseline
|
|
// and currentContent (the bytes the caller just read from disk) no longer matches
|
|
// it. An untracked path returns nil: with no baseline there is nothing to
|
|
// conflict against, so a first-touch write is always allowed.
|
|
func (tracker *FileTracker) CheckConflict(absPath string, currentContent []byte) error {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
version, ok := tracker.Version(absPath)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if HashContent(currentContent) != version.Hash {
|
|
return ErrFileChangedOnDisk
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HashContent returns the hex-encoded SHA-256 of content. Exported so the write
|
|
// tools and tests share one definition of a file's identity.
|
|
func HashContent(content []byte) string {
|
|
sum := sha256.Sum256(content)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// fileConflictMessage is the actionable error a write tool returns when a target
|
|
// changed on disk since it was last read, telling the model how to recover.
|
|
func fileConflictMessage(relativePath string) string {
|
|
return "Error writing " + relativePath + ": " + ErrFileChangedOnDisk.Error() +
|
|
" (it may have been edited outside Zero). Re-read it with read_file, then re-apply your change so you do not overwrite the newer content."
|
|
}
|