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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/internal/specs/cli-and-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ Machine-readable schema:
## Behavior summary

- `version`/`--version`: print `dotfiles-manager version <value>` 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

Expand Down
7 changes: 4 additions & 3 deletions docs/internal/specs/decision-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
7 changes: 7 additions & 0 deletions docs/internal/specs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions docs/user/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions docs/user/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docs/user/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 5 additions & 1 deletion 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 := scanSyncEntries(selection.TargetRoot, selection.ScopePrefix)
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, deployNeedsTargetWalk(syncCfg))
if err != nil {
return nil, deployCounts{}, err
}
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion 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 := scanSyncEntries(selection.TargetRoot, selection.ScopePrefix)
targetEntries, err := scanTargetEntries(selection.TargetRoot, selection.ScopePrefix, sourceEntries, importNeedsTargetWalk(syncCfg))
if err != nil {
return nil, importCounts{}, err
}
Expand Down Expand Up @@ -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))
}
Expand Down
80 changes: 79 additions & 1 deletion internal/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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{}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions internal/app/status_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down