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
5 changes: 4 additions & 1 deletion docs/user/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ Reports:
Candidate-set scanning is opt-in:
- unmanaged/removal candidate discovery runs only when related pattern lists are configured
- with default empty pattern lists, `status` compares manifest paths only
- even with a broad sync like `target: ./`, it does not scan unrelated target paths unless those pattern rules are enabled
- when enabled, discovery starts from literal pattern roots (for example `.codex/skills/**` starts at `.codex/skills`)
- wildcard-first patterns (for example `**/*.tmp`) can still require broad scans

Example text output shape:

Expand All @@ -87,6 +88,7 @@ Behavior:
- then remove unmanaged target paths matching `on.deploy.remove-unmanaged`
- if remove patterns are empty/missing, no unmanaged paths are removed
- with empty remove patterns, deploy does not perform unmanaged target-tree scanning
- with remove patterns present, scanning starts from literal pattern roots when available

`--dry-run` plans and reports operations without writing.

Expand All @@ -111,6 +113,7 @@ Behavior:
- replace type mismatches (file/dir/symlink)
- unmanaged add/remove-missing candidate discovery is include-gated
- with default empty include lists, import evaluates manifest paths only (no unmanaged target-tree scan)
- with add-unmanaged includes present, scanning starts from literal include roots when available

`--dry-run` plans and reports operations without writing.

Expand Down
3 changes: 2 additions & 1 deletion docs/user/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ Yes. Configure `on.deploy.remove-unmanaged` patterns.
No.

By default, unmanaged/missing candidate lists are disabled (`include: []`), so commands evaluate manifest paths only.
Broad target scans only happen when you explicitly enable unmanaged/missing pattern rules.
When pattern rules are enabled, discovery starts from literal pattern roots when possible (for example `.codex/skills/**` starts from `.codex/skills`).
Wildcard-first patterns (for example `**/*.tmp`) can still require broad scans.

## Can import add files that are not in source yet?

Expand Down
6 changes: 3 additions & 3 deletions internal/app/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func evaluateDeploySync(syncIndex int, syncCfg config.Sync, selection syncSelect
if err != nil {
return nil, deployCounts{}, err
}
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, deployNeedsTargetWalk(syncCfg))
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, deployTargetScanPatterns(syncCfg))
if err != nil {
return nil, deployCounts{}, err
}
Expand Down Expand Up @@ -197,8 +197,8 @@ func evaluateDeploySync(syncIndex int, syncCfg config.Sync, selection syncSelect
return payload, counts, nil
}

func deployNeedsTargetWalk(syncCfg config.Sync) bool {
return len(syncCfg.On.Deploy.RemoveUnmanaged) > 0
func deployTargetScanPatterns(syncCfg config.Sync) []string {
return syncCfg.On.Deploy.RemoveUnmanaged
}

func applyDeployCopy(op deployCopyOperation) error {
Expand Down
6 changes: 3 additions & 3 deletions internal/app/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func evaluateImportSync(syncIndex int, syncCfg config.Sync, selection syncSelect
if err != nil {
return nil, importCounts{}, err
}
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, importNeedsTargetWalk(syncCfg))
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, importTargetScanPatterns(syncCfg))
if err != nil {
return nil, importCounts{}, err
}
Expand Down Expand Up @@ -222,8 +222,8 @@ func evaluateImportSync(syncIndex int, syncCfg config.Sync, selection syncSelect
return payload, counts, nil
}

func importNeedsTargetWalk(syncCfg config.Sync) bool {
return len(syncCfg.On.Import.AddUnmanaged.Include) > 0
func importTargetScanPatterns(syncCfg config.Sync) []string {
return syncCfg.On.Import.AddUnmanaged.Include
}

func applyImportCopy(op importCopyOperation) error {
Expand Down
179 changes: 172 additions & 7 deletions internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -66,7 +67,7 @@ func evaluateStatusSync(syncIndex int, syncCfg config.Sync, selection syncSelect
if err != nil {
return nil, statusCounts{}, err
}
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, statusNeedsTargetWalk(syncCfg))
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, statusTargetScanPatterns(syncCfg))
if err != nil {
return nil, statusCounts{}, err
}
Expand Down Expand Up @@ -188,8 +189,11 @@ func evaluateStatusSync(syncIndex int, syncCfg config.Sync, selection syncSelect
return payload, counts, nil
}

func statusNeedsTargetWalk(syncCfg config.Sync) bool {
return len(syncCfg.On.Deploy.RemoveUnmanaged) > 0 || len(syncCfg.On.Import.AddUnmanaged.Include) > 0
func statusTargetScanPatterns(syncCfg config.Sync) []string {
patterns := make([]string, 0, len(syncCfg.On.Deploy.RemoveUnmanaged)+len(syncCfg.On.Import.AddUnmanaged.Include))
patterns = append(patterns, syncCfg.On.Deploy.RemoveUnmanaged...)
patterns = append(patterns, syncCfg.On.Import.AddUnmanaged.Include...)
return patterns
}

func buildStatusManagedOperation(phase, path, action, sourceType, targetType string) map[string]any {
Expand Down Expand Up @@ -230,7 +234,7 @@ func statusActionLabel(action string) string {
}
}

func scanTargetEntries(root, scopePrefix string, sourceEntries map[string]statusEntry, includeUnmanaged bool) (map[string]statusEntry, error) {
func scanTargetEntries(root, scopePrefix string, sourceEntries map[string]statusEntry, unmanagedScanPatterns []string) (map[string]statusEntry, error) {
entries := make(map[string]statusEntry, len(sourceEntries))

for relPath := range sourceEntries {
Expand All @@ -243,20 +247,181 @@ func scanTargetEntries(root, scopePrefix string, sourceEntries map[string]status
}
}

if !includeUnmanaged {
if len(unmanagedScanPatterns) == 0 {
return entries, nil
}

scannedEntries, err := scanSyncEntries(root, scopePrefix)
for _, prefix := range scanPrefixesForPatterns(unmanagedScanPatterns) {
scannedEntries, err := scanSyncEntriesForPrefix(root, scopePrefix, prefix)
if err != nil {
return nil, err
}
for relPath, entry := range scannedEntries {
entries[relPath] = entry
}
}

return entries, nil
}

func scanSyncEntriesForPrefix(root, scopePrefix, prefix string) (map[string]statusEntry, error) {
if prefix == "" {
return scanSyncEntries(root, scopePrefix)
}

absPrefix := filepath.Join(root, filepath.FromSlash(prefix))
if !isWithinTarget(filepath.Clean(absPrefix), filepath.Clean(root)) {
return map[string]statusEntry{}, nil
}

info, err := os.Lstat(absPrefix)
if err != nil {
if os.IsNotExist(err) {
return map[string]statusEntry{}, nil
}
return nil, dfmerr.Wrap(dfmerr.CodeIORead, fmt.Sprintf("Read failed: %s", absPrefix), map[string]any{"path": absPrefix}, err)
}

if !pathScopesOverlap(prefix, scopePrefix) {
return map[string]statusEntry{}, nil
}

if !info.IsDir() {
if !isPathInScope(prefix, scopePrefix) {
return map[string]statusEntry{}, nil
}
return map[string]statusEntry{
prefix: {
path: prefix,
absPath: absPrefix,
typeID: entryTypeFromInfo(info),
},
}, nil
}

entries := map[string]statusEntry{}
if isPathInScope(prefix, scopePrefix) {
entries[prefix] = statusEntry{
path: prefix,
absPath: absPrefix,
typeID: "dir",
}
}

subScope := scopedPrefix(scopePrefix, prefix)
scannedEntries, err := scanSyncEntries(absPrefix, subScope)
if err != nil {
return nil, err
}
for relPath, entry := range scannedEntries {
entries[relPath] = entry
joinedPath := joinSlashPath(prefix, relPath)
entry.path = joinedPath
entry.absPath = filepath.Join(root, filepath.FromSlash(joinedPath))
entries[joinedPath] = entry
}
return entries, nil
}

func scanPrefixesForPatterns(patterns []string) []string {
if len(patterns) == 0 {
return nil
}

unique := map[string]struct{}{}
for _, pattern := range patterns {
prefix := literalPatternPrefix(pattern)
if prefix == "" {
return []string{""}
}
unique[prefix] = struct{}{}
}

ordered := make([]string, 0, len(unique))
for prefix := range unique {
ordered = append(ordered, prefix)
}
sort.Strings(ordered)

pruned := make([]string, 0, len(ordered))
for _, prefix := range ordered {
covered := false
for _, keep := range pruned {
if prefix == keep || strings.HasPrefix(prefix, keep+"/") {
covered = true
break
}
}
if !covered {
pruned = append(pruned, prefix)
}
}
return pruned
}

func literalPatternPrefix(pattern string) string {
normalized := strings.TrimSpace(filepath.ToSlash(pattern))
normalized = strings.TrimPrefix(normalized, "./")
normalized = strings.TrimPrefix(normalized, "/")

if normalized == "" || normalized == "." {
return ""
}

segments := strings.Split(normalized, "/")
prefixSegments := make([]string, 0, len(segments))
for _, segment := range segments {
if segment == "" || segment == "." {
continue
}
if strings.ContainsAny(segment, "*?[{") {
break
}
prefixSegments = append(prefixSegments, segment)
}

if len(prefixSegments) == 0 {
return ""
}
return strings.Join(prefixSegments, "/")
}

func scopedPrefix(scopePrefix, prefix string) string {
if scopePrefix == "" {
return ""
}
if scopePrefix == prefix || strings.HasPrefix(prefix, scopePrefix+"/") {
return ""
}
if strings.HasPrefix(scopePrefix, prefix+"/") {
return strings.TrimPrefix(scopePrefix, prefix+"/")
}
return ""
}

func pathScopesOverlap(first, second string) bool {
if first == "" || second == "" {
return true
}
if first == second {
return true
}
return strings.HasPrefix(first, second+"/") || strings.HasPrefix(second, first+"/")
}

func joinSlashPath(parts ...string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" || part == "." {
continue
}
filtered = append(filtered, part)
}
if len(filtered) == 0 {
return ""
}
return path.Join(filtered...)
}

func scanSyncEntries(root, scopePrefix string) (map[string]statusEntry, error) {
entries := map[string]statusEntry{}

Expand Down
64 changes: 61 additions & 3 deletions internal/app/status_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ func TestScanTargetEntriesManifestOnlySkipsUnmanaged(t *testing.T) {
"managed.txt": {path: "managed.txt"},
}

targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, false)
targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, nil)
require.NoError(t, err)
require.Contains(t, targetEntries, "managed.txt")
require.NotContains(t, targetEntries, "unmanaged.txt")

targetEntries, err = scanTargetEntries(targetRoot, "", sourceEntries, true)
targetEntries, err = scanTargetEntries(targetRoot, "", sourceEntries, []string{"**"})
require.NoError(t, err)
require.Contains(t, targetEntries, "managed.txt")
require.Contains(t, targetEntries, "unmanaged.txt")
Expand All @@ -94,13 +94,71 @@ func TestScanTargetEntriesTreatsNotDirectoryAsMissing(t *testing.T) {
"lua/init.lua": {path: "lua/init.lua"},
}

targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, false)
targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, nil)
require.NoError(t, err)
require.Contains(t, targetEntries, "lua")
require.Equal(t, "file", targetEntries["lua"].typeID)
require.NotContains(t, targetEntries, "lua/init.lua")
}

func TestScanTargetEntriesPatternPrefixSkipsUnreadableSiblings(t *testing.T) {
t.Parallel()

targetRoot := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(targetRoot, "allowed"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(targetRoot, "allowed", "keep.txt"), []byte("x"), 0o644))

blocked := filepath.Join(targetRoot, "blocked")
require.NoError(t, os.MkdirAll(filepath.Join(blocked, "nested"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(blocked, "nested", "deny.txt"), []byte("x"), 0o644))

require.NoError(t, os.Chmod(blocked, 0o000))
t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) })

targetEntries, err := scanTargetEntries(targetRoot, "", map[string]statusEntry{}, []string{"allowed/**"})
require.NoError(t, err)
require.Contains(t, targetEntries, "allowed")
require.Contains(t, targetEntries, "allowed/keep.txt")
require.NotContains(t, targetEntries, "blocked")
}

func TestScanPrefixesForPatterns(t *testing.T) {
t.Parallel()

require.Equal(t, []string{""}, scanPrefixesForPatterns([]string{"**/*.tmp", ".codex/skills/**"}))

prefixes := scanPrefixesForPatterns([]string{
".codex/skills/**",
".codex/skills/custom/**",
".codex/prompts/**",
"foo/*/bar/**",
"./local/file.txt",
})

require.Equal(t, []string{".codex/prompts", ".codex/skills", "foo", "local/file.txt"}, prefixes)
}

func TestScopedPrefixAndOverlap(t *testing.T) {
t.Parallel()

require.True(t, pathScopesOverlap(".codex", ".codex/skills"))
require.True(t, pathScopesOverlap(".codex/skills", ".codex"))
require.False(t, pathScopesOverlap(".codex", ".config"))

require.Equal(t, "skills", scopedPrefix(".codex/skills", ".codex"))
require.Equal(t, "", scopedPrefix(".codex", ".codex/skills"))
require.Equal(t, "", scopedPrefix("", ".codex"))
}

func TestScanSyncEntriesForPrefixSkipsOutsideRoot(t *testing.T) {
t.Parallel()

root := t.TempDir()
entries, err := scanSyncEntriesForPrefix(root, "", "../outside")
require.NoError(t, err)
require.Empty(t, entries)
}

func TestEntryTypeFromDirEntryFallbackAndError(t *testing.T) {
t.Parallel()

Expand Down