From abcfd46478aa5a902f9a5abffdfc0ec84d1cb469 Mon Sep 17 00:00:00 2001 From: Leon Komarovsky Date: Tue, 17 Feb 2026 21:19:03 +0000 Subject: [PATCH 1/2] docs: clarify manifest-only defaults and root target scan behavior --- README.md | 1 + docs/internal/specs/cli-and-config-spec.md | 3 ++- docs/internal/specs/decision-matrix.md | 7 ++++--- docs/internal/specs/decisions.md | 7 +++++++ docs/user/README.md | 1 + docs/user/commands.md | 8 ++++++++ docs/user/configuration.md | 3 +++ docs/user/faq.md | 7 +++++++ 8 files changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4fdbb82..6ef471e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ GitHub Releases publish: - deploy cleanup: `on.deploy.remove-unmanaged` - import unmanaged adds: `on.import.add-unmanaged.include/exclude` - import missing deletes: `on.import.remove-missing.include/exclude` + - defaults are safe (`[]`): unmanaged/missing candidate scans stay off unless explicitly configured 4. **Scoped runs** - optional `[path]` narrows commands to matching target subpaths diff --git a/docs/internal/specs/cli-and-config-spec.md b/docs/internal/specs/cli-and-config-spec.md index 940f7e7..1c32d91 100644 --- a/docs/internal/specs/cli-and-config-spec.md +++ b/docs/internal/specs/cli-and-config-spec.md @@ -71,9 +71,10 @@ Machine-readable schema: ## Behavior summary - `version`/`--version`: print `dotfiles-manager version ` and exit (`dev` for non-release local builds) -- `status`: report drift and candidate sets +- `status`: report drift and candidate sets; unmanaged/missing candidates are pattern-gated - `deploy`: source -> target; optional unmanaged removal by patterns - `import`: target -> source; optional unmanaged adds + optional missing deletes by patterns +- with default empty pattern lists, commands evaluate manifest paths only (no broad unmanaged target scan) ## Output model summary diff --git a/docs/internal/specs/decision-matrix.md b/docs/internal/specs/decision-matrix.md index 0daad2f..eb2d19b 100644 --- a/docs/internal/specs/decision-matrix.md +++ b/docs/internal/specs/decision-matrix.md @@ -24,9 +24,9 @@ Canonical rules and rationale live in **`decisions.md`**. | Command | Direction | Base scope | Pattern sets used | Outcome focus | |---|---|---|---|---| | `version` / `--version` | info | n/a | none | Reports CLI version and exits. | -| `status [--json] [path]` | compare | Manifest + candidates | add-unmanaged include/exclude, remove-unmanaged, remove-missing include/exclude | Reports drift + candidate sets. | -| `deploy [--dry-run] [--json] [path]` | S → T | Manifest paths | remove-unmanaged | Applies copy/remove behavior (or plans only with `--dry-run`). | -| `import [--dry-run] [--json] [path]` | T → S | Manifest paths | add-unmanaged include/exclude, remove-missing include/exclude | Applies import behavior (or plans only with `--dry-run`). | +| `status [--json] [path]` | compare | Manifest paths by default; candidate expansion when pattern-gated | add-unmanaged include/exclude, remove-unmanaged, remove-missing include/exclude | Reports drift + candidate sets. | +| `deploy [--dry-run] [--json] [path]` | S → T | Manifest paths; unmanaged removals only for matching patterns | remove-unmanaged | Applies copy/remove behavior (or plans only with `--dry-run`). | +| `import [--dry-run] [--json] [path]` | T → S | Manifest paths by default; unmanaged/missing expansion when include-gated | add-unmanaged include/exclude, remove-missing include/exclude | Applies import behavior (or plans only with `--dry-run`). | ## 2) `[path]` subset matrix @@ -64,6 +64,7 @@ Examples: - `source` is authoritative; there is no separate conflict state. - Deploy removal order is copy/update first, remove second. - Status should include candidate visibility (incoming unmanaged, removable unmanaged, removable missing). +- Empty default pattern lists imply no broad unmanaged target scan. - Status text/json actions use potential wording (`can create`, `can update`, `can replace type`, `can add`, `can remove`). - Text output suppresses empty phase blocks; text summary omits zero-count categories. - `--dry-run` for deploy/import uses the same scope and outcome planning, but performs no writes. diff --git a/docs/internal/specs/decisions.md b/docs/internal/specs/decisions.md index 0d67234..fbaa05d 100644 --- a/docs/internal/specs/decisions.md +++ b/docs/internal/specs/decisions.md @@ -79,6 +79,11 @@ Reports: - incoming unmanaged candidates - removable unmanaged candidates - removable missing-manifest candidates +- candidate discovery is pattern-gated: + - `on.import.add-unmanaged.include/exclude` + - `on.deploy.remove-unmanaged` + - `on.import.remove-missing.include/exclude` +- with default empty pattern lists, status evaluates manifest paths only (no broad unmanaged target scan) Output semantics: - status operation wording is potential/human-readable (`can create`, `can update`, `can replace type`, `can add`, `can remove`) @@ -91,6 +96,7 @@ JSON format is defined in `../contracts/json-contract.md`. - Copy/update only when content differs. - Type mismatches are replaced to match source type. - If remove patterns are empty/missing, no unmanaged removal occurs. +- If remove patterns are empty/missing, deploy does not perform unmanaged target-tree scanning. - With `[path]`, cleanup/removal applies only in the scoped subtree. - Order is **copy then remove**. - Preserve metadata as much as the platform supports. @@ -102,6 +108,7 @@ JSON format is defined in `../contracts/json-contract.md`. - Unmanaged add candidates: target-only + add-unmanaged include match + not add-unmanaged exclude. - Missing-delete candidates: source-only (missing in target) + remove-missing include match + not remove-missing exclude. - Default (without remove-missing include patterns): do not delete source entries just because target is missing. +- With default empty include lists, import evaluates manifest paths only (no unmanaged target-tree scan). - Type mismatches are replaced to match target type. - Preserve metadata as much as the platform supports. - With `--dry-run`, plan and report actions but do not mutate filesystem. diff --git a/docs/user/README.md b/docs/user/README.md index b35d3e0..102c0b7 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -29,6 +29,7 @@ Main commands: - Config format is YAML. - Default discovery is current-directory only (no parent-directory search). - Config JSON Schema is available at `../internal/contracts/config-schema.json` for editor/tooling validation. +- Unmanaged/missing behavior is opt-in by patterns; empty defaults evaluate manifest paths only. - Logs are always written to a log file. - macOS default: `~/Library/Logs/dotfiles-manager/dotfiles-manager.log` - Linux default: `${XDG_STATE_HOME:-~/.local/state}/dotfiles-manager/dotfiles-manager.log` diff --git a/docs/user/commands.md b/docs/user/commands.md index 3c41f23..7c1bfbd 100644 --- a/docs/user/commands.md +++ b/docs/user/commands.md @@ -61,6 +61,11 @@ Reports: `status` does not write files. +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 + Example text output shape: ```text @@ -81,6 +86,7 @@ Behavior: - replace type mismatches (file/dir/symlink) - 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 `--dry-run` plans and reports operations without writing. @@ -103,6 +109,8 @@ Behavior: - optionally add unmanaged target files via `on.import.add-unmanaged.include/exclude` - optionally remove source paths missing in target via `on.import.remove-missing.include/exclude` - 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) `--dry-run` plans and reports operations without writing. diff --git a/docs/user/configuration.md b/docs/user/configuration.md index 5cf7eda..23ec5e1 100644 --- a/docs/user/configuration.md +++ b/docs/user/configuration.md @@ -69,6 +69,9 @@ Optional behavior keys: - no unmanaged removal on deploy - no unmanaged import on import - no delete-on-missing on import +- include-gated candidate sets stay disabled by default (`include: []`) +- practical effect: only manifest paths are evaluated unless you opt into unmanaged/missing patterns +- this safety default still applies for broad targets (for example `target: ./`) ## Pattern behavior diff --git a/docs/user/faq.md b/docs/user/faq.md index c2e746b..d4304bd 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -28,6 +28,13 @@ A command-scoped `[path]` only selects syncs where that path is the sync target Yes. Configure `on.deploy.remove-unmanaged` patterns. +## Will `target: ./` scan my whole home directory by default? + +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. + ## Can import add files that are not in source yet? Yes. Configure `on.import.add-unmanaged.include` (and optional exclude). From b4dadec6367da0081023ea5fee78eb2ad111457b Mon Sep 17 00:00:00 2001 From: Leon Komarovsky Date: Tue, 17 Feb 2026 21:27:00 +0000 Subject: [PATCH 2/2] fix(sync): avoid broad target scans unless unmanaged patterns are enabled --- internal/app/deploy.go | 6 ++- internal/app/import.go | 6 ++- internal/app/status.go | 80 ++++++++++++++++++++++++++++- internal/app/status_helpers_test.go | 58 +++++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) diff --git a/internal/app/deploy.go b/internal/app/deploy.go index 6795a80..687c40b 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 := scanSyncEntries(selection.TargetRoot, selection.ScopePrefix) + targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, deployNeedsTargetWalk(syncCfg)) if err != nil { return nil, deployCounts{}, err } @@ -197,6 +197,10 @@ 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 applyDeployCopy(op deployCopyOperation) error { if op.change == "replace_type" { if err := removePath(op.targetAbs); err != nil { diff --git a/internal/app/import.go b/internal/app/import.go index 6580b81..4030dff 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 := scanSyncEntries(selection.TargetRoot, selection.ScopePrefix) + targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, importNeedsTargetWalk(syncCfg)) if err != nil { return nil, importCounts{}, err } @@ -222,6 +222,10 @@ 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 applyImportCopy(op importCopyOperation) error { return applyDeployCopy(deployCopyOperation(op)) } diff --git a/internal/app/status.go b/internal/app/status.go index 1cae337..ad3350c 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -2,12 +2,14 @@ package app import ( "bytes" + "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" + "syscall" "github.com/bmatcuk/doublestar/v4" "github.com/shpoont/dotfiles-manager/internal/config" @@ -64,7 +66,7 @@ func evaluateStatusSync(syncIndex int, syncCfg config.Sync, selection syncSelect if err != nil { return nil, statusCounts{}, err } - targetEntries, err := scanSyncEntries(selection.TargetRoot, selection.ScopePrefix) + targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, statusNeedsTargetWalk(syncCfg)) if err != nil { return nil, statusCounts{}, err } @@ -186,6 +188,10 @@ 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 buildStatusManagedOperation(phase, path, action, sourceType, targetType string) map[string]any { return map[string]any{ "phase": phase, @@ -224,6 +230,33 @@ func statusActionLabel(action string) string { } } +func scanTargetEntries(root, scopePrefix string, sourceEntries map[string]statusEntry, includeUnmanaged bool) (map[string]statusEntry, error) { + entries := make(map[string]statusEntry, len(sourceEntries)) + + for relPath := range sourceEntries { + entry, exists, err := probeSyncEntry(root, relPath) + if err != nil { + return nil, err + } + if exists { + entries[relPath] = entry + } + } + + if !includeUnmanaged { + return entries, nil + } + + scannedEntries, err := scanSyncEntries(root, scopePrefix) + if err != nil { + return nil, err + } + for relPath, entry := range scannedEntries { + entries[relPath] = entry + } + return entries, nil +} + func scanSyncEntries(root, scopePrefix string) (map[string]statusEntry, error) { entries := map[string]statusEntry{} @@ -254,6 +287,9 @@ func scanSyncEntries(root, scopePrefix string) (map[string]statusEntry, error) { relPath = filepath.ToSlash(relPath) if !isPathInScope(relPath, scopePrefix) { + if d.IsDir() && !pathCanContainScope(relPath, scopePrefix) { + return filepath.SkipDir + } return nil } @@ -276,6 +312,27 @@ func scanSyncEntries(root, scopePrefix string) (map[string]statusEntry, error) { return entries, nil } +func probeSyncEntry(root, relPath string) (statusEntry, bool, error) { + absPath := filepath.Join(root, filepath.FromSlash(relPath)) + info, err := os.Lstat(absPath) + if err != nil { + if pathMissing(err) { + return statusEntry{}, false, nil + } + return statusEntry{}, false, dfmerr.Wrap(dfmerr.CodeIORead, fmt.Sprintf("Read failed: %s", absPath), map[string]any{"path": absPath}, err) + } + + return statusEntry{ + path: relPath, + absPath: absPath, + typeID: entryTypeFromInfo(info), + }, true, nil +} + +func pathMissing(err error) bool { + return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) +} + func isPathInScope(path, scopePrefix string) bool { if scopePrefix == "" { return true @@ -286,6 +343,27 @@ func isPathInScope(path, scopePrefix string) bool { return strings.HasPrefix(path, scopePrefix+"/") } +func pathCanContainScope(path, scopePrefix string) bool { + if scopePrefix == "" { + return true + } + if path == "" { + return true + } + return strings.HasPrefix(scopePrefix, path+"/") +} + +func entryTypeFromInfo(info os.FileInfo) string { + mode := info.Mode() + if mode&os.ModeSymlink != 0 { + return "symlink" + } + if info.IsDir() { + return "dir" + } + return "file" +} + func entryTypeFromDirEntry(path string, d fs.DirEntry) (string, error) { mode := d.Type() if mode&os.ModeSymlink != 0 { diff --git a/internal/app/status_helpers_test.go b/internal/app/status_helpers_test.go index c75b69e..7be22ff 100644 --- a/internal/app/status_helpers_test.go +++ b/internal/app/status_helpers_test.go @@ -43,6 +43,64 @@ func TestScanSyncEntriesScopeFiltering(t *testing.T) { require.NotContains(t, entries, "other/b.lua") } +func TestScanSyncEntriesScopeFilteringKeepsScopeAncestors(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "lua", "sub"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "other"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "lua", "sub", "a.lua"), []byte("a"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "other", "b.lua"), []byte("b"), 0o644)) + + entries, err := scanSyncEntries(root, "lua/sub") + require.NoError(t, err) + require.NotContains(t, entries, "lua") + require.Contains(t, entries, "lua/sub") + require.Contains(t, entries, "lua/sub/a.lua") + require.NotContains(t, entries, "other") + require.NotContains(t, entries, "other/b.lua") +} + +func TestScanTargetEntriesManifestOnlySkipsUnmanaged(t *testing.T) { + t.Parallel() + + targetRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(targetRoot, "managed.txt"), []byte("managed"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(targetRoot, "unmanaged.txt"), []byte("unmanaged"), 0o644)) + + sourceEntries := map[string]statusEntry{ + "managed.txt": {path: "managed.txt"}, + } + + targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, false) + require.NoError(t, err) + require.Contains(t, targetEntries, "managed.txt") + require.NotContains(t, targetEntries, "unmanaged.txt") + + targetEntries, err = scanTargetEntries(targetRoot, "", sourceEntries, true) + require.NoError(t, err) + require.Contains(t, targetEntries, "managed.txt") + require.Contains(t, targetEntries, "unmanaged.txt") +} + +func TestScanTargetEntriesTreatsNotDirectoryAsMissing(t *testing.T) { + t.Parallel() + + targetRoot := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(targetRoot, "lua"), []byte("not-a-dir"), 0o644)) + + sourceEntries := map[string]statusEntry{ + "lua": {path: "lua"}, + "lua/init.lua": {path: "lua/init.lua"}, + } + + targetEntries, err := scanTargetEntries(targetRoot, "", sourceEntries, false) + require.NoError(t, err) + require.Contains(t, targetEntries, "lua") + require.Equal(t, "file", targetEntries["lua"].typeID) + require.NotContains(t, targetEntries, "lua/init.lua") +} + func TestEntryTypeFromDirEntryFallbackAndError(t *testing.T) { t.Parallel()