Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions internal/cli/cmdagent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -330,9 +331,21 @@ func readAnswer(cmd *cobra.Command, args []string, fromFile string) (string, err
}
return string(data), nil
case fromFile != "":
data, err := os.ReadFile(fromFile)
// Normalise the path: collapse "..", unify separators, and resolve a
// relative path against the working directory. Without this, a caller
// passing e.g. "C:\a\b/../c/x.md" (mixed separators + parent refs, as
// shells and scratch-dir helpers routinely produce) hit
// "cannot find the path specified" even though the file exists.
p := filepath.FromSlash(fromFile)
if !filepath.IsAbs(p) {
if wd, werr := os.Getwd(); werr == nil {
p = filepath.Join(wd, p)
}
}
p = filepath.Clean(p)
data, err := os.ReadFile(p)
if err != nil {
return "", errcode.New(ErrAgentFailed, "read answer file", err)
return "", errcode.New(ErrAgentFailed, "read answer file "+p, err)
}
return string(data), nil
default:
Expand Down
102 changes: 97 additions & 5 deletions internal/cli/cmdclean/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,70 @@ func isForgeTrash(rel string) bool {
return rel == forgeTrashRel || strings.HasPrefix(rel, forgeTrashRel+"/")
}

// IncludeIgnored disables the default behaviour of skipping git-ignored paths
// (outside .forge/) during classification. Set by `forge clean --include-ignored`.
// Left as a package var so Run/RunDryRun/RunWithTrash keep their existing
// signatures (ship.go and the test suite call them directly).
var IncludeIgnored bool

// gitignoreFilter identifies paths git ignores so `forge clean` leaves other
// tools' ignored working files alone (e.g. .playwright-mcp/ console logs,
// editor caches, sibling .claude/worktrees/). Forge's OWN ignored scratch
// under .forge/ is still classified — tidying that is the point of the command.
type gitignoreFilter struct {
files map[string]bool // exact ignored paths (slash-separated, root-relative)
dirs []string // ignored directory prefixes, each ending in "/"
}

// newGitignoreFilter builds the ignore set via `git ls-files`. When
// includeIgnored is true, or git is unavailable, it returns a filter that
// matches nothing (pre-1.10.6 behaviour).
func newGitignoreFilter(root string, includeIgnored bool) *gitignoreFilter {
f := &gitignoreFilter{files: map[string]bool{}}
if includeIgnored {
return f
}
// -o -i --exclude-standard --directory: list ignored paths, collapsing a
// fully-ignored directory to a single "dir/" entry instead of every file.
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "-o", "-i",
"--exclude-standard", "--directory")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return f // git unavailable — degrade to "filter nothing"
}
for _, entry := range strings.Split(out.String(), "\x00") {
if entry == "" {
continue
}
entry = filepath.ToSlash(entry)
if strings.HasSuffix(entry, "/") {
f.dirs = append(f.dirs, entry)
} else {
f.files[entry] = true
}
}
return f
}

// ignored reports whether rel (slash-separated, root-relative) is git-ignored
// and lives outside forge's own .forge/ tree.
func (f *gitignoreFilter) ignored(rel string) bool {
if rel == ".forge" || strings.HasPrefix(rel, ".forge/") {
return false // forge still cleans its own scratch even when gitignored
}
if f.files[rel] {
return true
}
relDir := rel + "/"
for _, d := range f.dirs {
if strings.HasPrefix(relDir, d) {
return true
}
}
return false
}

// loadMerged loads scratch/managed patterns from both .forge/manifest and
// .forge/hygiene.yml (if present), returning the union of both. This ensures
// forge clean is consistent with forge hygiene's pattern set (issue #15).
Expand Down Expand Up @@ -164,17 +228,19 @@ func init() {
// New returns the cobra command.
func New() *cobra.Command {
var (
root string
check bool
dryRun bool
apply bool
asJSON bool
root string
check bool
dryRun bool
apply bool
asJSON bool
includeIgnored bool
)
cmd := &cobra.Command{
Use: "clean",
Short: "Find/remove unmanaged scratch files.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
IncludeIgnored = includeIgnored
modes := 0
if check {
modes++
Expand Down Expand Up @@ -242,6 +308,8 @@ func New() *cobra.Command {
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&apply, "apply", false, "move found candidates to .forge/trash/<run-id>/")
cmd.Flags().BoolVar(&asJSON, "json", false, "emit machine-readable JSON")
cmd.Flags().BoolVar(&includeIgnored, "include-ignored", false,
"also classify git-ignored paths outside .forge/ (pre-1.10.6 behaviour)")
return cmd
}

Expand All @@ -258,6 +326,7 @@ func Run(root string, apply bool) (*Result, error) {
mode = "apply"
}
res := &Result{Root: root, ManifestPath: mf.Path, Mode: mode}
gi := newGitignoreFilter(root, IncludeIgnored)

walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
Expand All @@ -281,6 +350,13 @@ func Run(root string, apply bool) (*Result, error) {
}
return nil
}
if gi.ignored(rel) {
// git-ignored working file from another tool — not forge's to remove.
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if mf.IsScratch(rel) {
res.Candidates = append(res.Candidates, rel)
if d.IsDir() {
Expand Down Expand Up @@ -322,6 +398,7 @@ func RunDryRun(root string) (*Result, error) {
return nil, err
}
res := &Result{Root: root, ManifestPath: mf.Path, Mode: "dry-run"}
gi := newGitignoreFilter(root, IncludeIgnored)
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil || p == root {
return werr
Expand All @@ -337,6 +414,13 @@ func RunDryRun(root string) (*Result, error) {
}
return nil
}
if gi.ignored(rel) {
// git-ignored working file from another tool — not forge's to remove.
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if mf.IsScratch(rel) {
res.Candidates = append(res.Candidates, rel)
if d.IsDir() {
Expand All @@ -357,6 +441,7 @@ func RunWithTrash(root string) (*Result, error) {
return nil, err
}
res := &Result{Root: root, ManifestPath: mf.Path, Mode: "apply"}
gi := newGitignoreFilter(root, IncludeIgnored)
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil || p == root {
return werr
Expand All @@ -372,6 +457,13 @@ func RunWithTrash(root string) (*Result, error) {
}
return nil
}
if gi.ignored(rel) {
// git-ignored working file from another tool — not forge's to remove.
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if mf.IsScratch(rel) {
res.Candidates = append(res.Candidates, rel)
if d.IsDir() {
Expand Down
Loading
Loading