Files
zero/internal/tools/file_tracker.go
PierrunoYTandCopilot App 062328b632 fix: warn about untracked scratch files left behind after a run (#571)
* 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]>
2026-07-09 09:25:09 +08:00

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."
}