diff --git a/docs/user/commands.md b/docs/user/commands.md index 0be3a85..a5096eb 100644 --- a/docs/user/commands.md +++ b/docs/user/commands.md @@ -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: @@ -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. @@ -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. diff --git a/docs/user/faq.md b/docs/user/faq.md index e5b2d9b..f740c67 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -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? diff --git a/internal/app/deploy.go b/internal/app/deploy.go index 687c40b..c541418 100644 --- a/internal/app/deploy.go +++ b/internal/app/deploy.go @@ -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 } @@ -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 { diff --git a/internal/app/import.go b/internal/app/import.go index 4030dff..a92757e 100644 --- a/internal/app/import.go +++ b/internal/app/import.go @@ -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 } @@ -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 { diff --git a/internal/app/status.go b/internal/app/status.go index ad3350c..a386fa4 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "sort" "strings" @@ -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 } @@ -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 { @@ -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 { @@ -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{} diff --git a/internal/app/status_helpers_test.go b/internal/app/status_helpers_test.go index 7be22ff..3dd3fd6 100644 --- a/internal/app/status_helpers_test.go +++ b/internal/app/status_helpers_test.go @@ -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") @@ -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()