Files
zero/internal/cli/scratch_files_test.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

175 lines
6.0 KiB
Go

package cli
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func runGitForScratchTest(t *testing.T, dir string, args ...string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "[email protected]",
"GIT_COMMITTER_NAME=test", "[email protected]",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
func TestScratchFileWarningFlagsUntrackedCreatedFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug file.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if warning == "" {
t.Fatal("expected a warning about the untracked scratch file")
}
if !strings.Contains(warning, "_debug file.py") {
t.Fatalf("expected warning to mention _debug file.py, got %q", warning)
}
}
func TestScratchFileWarningDetectsShellCreatedScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
// Simulate a shell heredoc/redirect creating a scratch file. The warning is
// based on the git before/after state, not only FileTracker-created paths.
if err := os.WriteFile(filepath.Join(root, "_fix_test.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
warning := scratchFileWarning(root, baseline)
if !strings.Contains(warning, "_fix_test.py") {
t.Fatalf("expected warning to mention shell-created _fix_test.py, got %q", warning)
}
}
func TestScratchFileWarningSilentForNormalDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "app.py"), []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a normal new deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentForUnderscoreDeliverableFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "README.md")
runGitForScratchTest(t, root, "commit", "-m", "init")
baseline := scratchFileSnapshot(root)
if err := os.WriteFile(filepath.Join(root, "_generated.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for an underscore-prefixed deliverable file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileIsTracked(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
trackedPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(trackedPath, []byte("print('app')"), 0o644); err != nil {
t.Fatal(err)
}
runGitForScratchTest(t, root, "add", "_debug.py")
runGitForScratchTest(t, root, "commit", "-m", "init")
// A scratch-like file subsequently staged/committed by the
// model itself is no longer a loose scratch file, so no warning.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a tracked file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNoFilesCreated(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning when nothing was created, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenFileWasRemovedAgain(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
baseline := scratchFileSnapshot(root)
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(scratchPath); err != nil {
t.Fatal(err)
}
// The model created and then cleaned up its own scratch file — nothing to warn about.
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a removed file, got %q", warning)
}
}
func TestScratchFileWarningSilentForPreexistingScratchFile(t *testing.T) {
root := t.TempDir()
runGitForScratchTest(t, root, "init")
if err := os.WriteFile(filepath.Join(root, "_debug.py"), []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
baseline := scratchFileSnapshot(root)
if warning := scratchFileWarning(root, baseline); warning != "" {
t.Fatalf("expected no warning for a pre-existing scratch file, got %q", warning)
}
}
func TestScratchFileWarningSilentWhenNotAGitRepo(t *testing.T) {
root := t.TempDir()
scratchPath := filepath.Join(root, "_debug.py")
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
t.Fatal(err)
}
if warning := scratchFileWarning(root, scratchFileSnapshot(root)); warning != "" {
t.Fatalf("expected no warning outside a git repo, got %q", warning)
}
}