cdf-shell 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdf/__init__.py ADDED
File without changes
cdf/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from cdf.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
cdf/app.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import os
5
+ from collections.abc import Collection
6
+ from pathlib import Path
7
+
8
+ from cdf.errors import NoCandidatesError
9
+ from cdf.picker import Picker
10
+ from cdf.walker import DirectoryWalker
11
+
12
+
13
+ def resolve(
14
+ term: str,
15
+ root: Path,
16
+ *,
17
+ git_only: bool,
18
+ include_hidden: bool,
19
+ ignore: Collection[str],
20
+ max_depth: int | None = None,
21
+ one_file_system: bool = False,
22
+ walker: DirectoryWalker,
23
+ picker: Picker,
24
+ ) -> Path | None:
25
+ candidates = walker.walk(
26
+ root,
27
+ git_only=git_only,
28
+ include_hidden=include_hidden,
29
+ ignore=ignore,
30
+ max_depth=max_depth,
31
+ one_file_system=one_file_system,
32
+ )
33
+ # Peek at the first candidate so an empty tree gets a clear message instead
34
+ # of an empty picker; the rest keeps streaming lazily into the picker.
35
+ first = next(candidates, None)
36
+ if first is None:
37
+ raise NoCandidatesError(_no_candidates_message(root, git_only=git_only))
38
+ return picker.pick(itertools.chain([first], candidates), term, root=root)
39
+
40
+
41
+ def _enclosing_repo(root: Path) -> Path | None:
42
+ # os.path.exists never raises (Path.exists can, on Python < 3.14, for an
43
+ # unsearchable ancestor); this only picks the wording of a message.
44
+ return next((path for path in (root, *root.parents) if os.path.exists(path / ".git")), None)
45
+
46
+
47
+ def _no_candidates_message(root: Path, *, git_only: bool) -> str:
48
+ if not git_only:
49
+ return f"no directories found under {root}"
50
+ # The walk skips the root itself, so running inside a repo only finds
51
+ # repos nested in it; say so rather than implying there are none at all.
52
+ repo = _enclosing_repo(root)
53
+ if repo is not None:
54
+ return (
55
+ f"no nested git repositories found under {root}, which is inside the "
56
+ f"repository {repo} (try --no-git)"
57
+ )
58
+ return f"no git repositories found under {root} (try --no-git)"
cdf/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from importlib import resources
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from pathlib import Path
9
+
10
+ from cdf.app import resolve
11
+ from cdf.config import default_config_paths, resolve_config
12
+ from cdf.display import printable
13
+ from cdf.errors import CdfError, NoCandidatesError
14
+ from cdf.picker import FzfPicker, Picker
15
+ from cdf.walker import DirectoryWalker, FilesystemWalker
16
+
17
+ _EXIT_SELECTED = 0
18
+ _EXIT_NO_SELECTION = 1
19
+ _EXIT_ERROR = 2
20
+ _EXIT_INTERRUPTED = 130
21
+ # bash and zsh share one POSIX-style wrapper; fish needs its own.
22
+ _INIT_SCRIPTS = {"bash": "cdf.sh", "zsh": "cdf.sh", "fish": "cdf.fish"}
23
+
24
+
25
+ def create_walker() -> DirectoryWalker:
26
+ return FilesystemWalker()
27
+
28
+
29
+ def create_picker() -> Picker:
30
+ return FzfPicker()
31
+
32
+
33
+ def _package_version() -> str:
34
+ try:
35
+ return version("cdf-shell")
36
+ except PackageNotFoundError:
37
+ return "unknown"
38
+
39
+
40
+ def _resolve_config_path(cli_config: Path | None) -> Path | None:
41
+ if cli_config is not None:
42
+ return cli_config
43
+ env_config = os.environ.get("CDF_CONFIG_PATH")
44
+ return Path(env_config) if env_config else None
45
+
46
+
47
+ def _non_negative_int(text: str) -> int:
48
+ try:
49
+ value = int(text)
50
+ except ValueError:
51
+ value = -1
52
+ if value < 0:
53
+ raise argparse.ArgumentTypeError(f"expected a non-negative integer, got {text!r}")
54
+ return value
55
+
56
+
57
+ def shell_init_script(shell: str) -> str:
58
+ script = resources.files("cdf").joinpath("shell", _INIT_SCRIPTS[shell])
59
+ return script.read_text(encoding="utf-8")
60
+
61
+
62
+ def _write_stdout(data: bytes) -> bool:
63
+ """Write raw bytes to stdout; False if the reader has already gone away."""
64
+ try:
65
+ sys.stdout.flush()
66
+ sys.stdout.buffer.write(data)
67
+ sys.stdout.buffer.flush()
68
+ except BrokenPipeError:
69
+ # Point stdout at /dev/null so the interpreter's own flush at exit
70
+ # doesn't raise (and print a traceback) a second time.
71
+ devnull = os.open(os.devnull, os.O_WRONLY)
72
+ os.dup2(devnull, sys.stdout.fileno())
73
+ os.close(devnull)
74
+ return False
75
+ return True
76
+
77
+
78
+ def build_parser() -> argparse.ArgumentParser:
79
+ parser = argparse.ArgumentParser(
80
+ prog="cdf",
81
+ description="Interactively find a directory below a path and print it.",
82
+ epilog=(
83
+ "This binary only prints the resolved path; it never changes your shell's "
84
+ 'working directory itself. Add `eval "$(command cdf --init bash)"` (or zsh) '
85
+ "to your shell rc, or `command cdf --init fish | source` to config.fish: it "
86
+ "defines a `cdf` shell function (which takes priority "
87
+ "over this binary and calls it internally via `command cdf`) that actually "
88
+ "cds into the result.\n\n"
89
+ "Defaults shown below can also be set in the config file (-c/--config); "
90
+ "CLI flags always win.\n\n"
91
+ "Exit codes: 0 = a directory was selected, 1 = nothing was selected "
92
+ "(cancelled or no matches), 2 = an error occurred (see stderr)."
93
+ ),
94
+ formatter_class=argparse.RawDescriptionHelpFormatter,
95
+ # No "--ver" for "--version" etc.: the shell wrappers recognise the
96
+ # informational flags by their exact spelling.
97
+ allow_abbrev=False,
98
+ )
99
+ parser.add_argument(
100
+ "term", nargs="*", default=[], help="initial fuzzy filter text (words are joined)"
101
+ )
102
+ parser.add_argument(
103
+ "-p",
104
+ "--path",
105
+ type=Path,
106
+ default=None,
107
+ help="root to search from (default: cwd)",
108
+ )
109
+ parser.add_argument(
110
+ "--git",
111
+ dest="git",
112
+ action=argparse.BooleanOptionalAction,
113
+ default=None,
114
+ help="only match git repository roots (default: on)",
115
+ )
116
+ parser.add_argument(
117
+ "-a",
118
+ "--all",
119
+ dest="hidden",
120
+ action=argparse.BooleanOptionalAction,
121
+ default=None,
122
+ help="include hidden/dot directories (default: off)",
123
+ )
124
+ parser.add_argument(
125
+ "-d",
126
+ "--max-depth",
127
+ type=_non_negative_int,
128
+ default=None,
129
+ metavar="N",
130
+ help="only look N levels below the root; 0 = no limit (default: no limit)",
131
+ )
132
+ parser.add_argument(
133
+ "-x",
134
+ "--one-file-system",
135
+ dest="one_file_system",
136
+ action=argparse.BooleanOptionalAction,
137
+ default=None,
138
+ help="list mount points but don't descend into them (default: off)",
139
+ )
140
+ parser.add_argument(
141
+ "-c",
142
+ "--config",
143
+ type=Path,
144
+ default=None,
145
+ help=(
146
+ "config file path (default: $CDF_CONFIG_PATH, else first existing of "
147
+ + (", ".join(str(path) for path in default_config_paths()) or "none")
148
+ + ")"
149
+ ),
150
+ )
151
+ parser.add_argument(
152
+ "--init",
153
+ choices=tuple(_INIT_SCRIPTS),
154
+ default=None,
155
+ metavar="SHELL",
156
+ help=f"print the shell integration for SHELL ({', '.join(_INIT_SCRIPTS)}) and exit",
157
+ )
158
+ parser.add_argument(
159
+ "--version",
160
+ action="version",
161
+ version=f"%(prog)s {_package_version()}",
162
+ )
163
+ return parser
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ parser = build_parser()
168
+ args = parser.parse_args(argv)
169
+
170
+ if args.init is not None:
171
+ written = _write_stdout(shell_init_script(args.init).encode())
172
+ return _EXIT_SELECTED if written else _EXIT_ERROR
173
+
174
+ try:
175
+ config = resolve_config(
176
+ config_path=_resolve_config_path(args.config),
177
+ cli_path=args.path,
178
+ cli_git=args.git,
179
+ cli_hidden=args.hidden,
180
+ cli_max_depth=args.max_depth,
181
+ cli_one_file_system=args.one_file_system,
182
+ )
183
+ target = resolve(
184
+ " ".join(args.term),
185
+ config.path,
186
+ git_only=config.git,
187
+ include_hidden=config.hidden,
188
+ ignore=config.ignore,
189
+ max_depth=config.max_depth,
190
+ one_file_system=config.one_file_system,
191
+ walker=create_walker(),
192
+ picker=create_picker(),
193
+ )
194
+ except KeyboardInterrupt:
195
+ return _EXIT_INTERRUPTED
196
+ except NoCandidatesError as exc:
197
+ # Messages can embed directory names; never echo control characters.
198
+ print(printable(str(exc)), file=sys.stderr)
199
+ return _EXIT_NO_SELECTION
200
+ except CdfError as exc:
201
+ print(printable(str(exc)), file=sys.stderr)
202
+ return _EXIT_ERROR
203
+
204
+ if target is None:
205
+ return _EXIT_NO_SELECTION
206
+
207
+ # Raw bytes: a non-UTF-8 name can't go through the text layer under a
208
+ # strict locale (e.g. en_US.UTF-8), and the shell needs the exact bytes.
209
+ # If nobody read the path, it wasn't delivered: report that, not success.
210
+ written = _write_stdout(os.fsencode(target) + b"\n")
211
+ return _EXIT_SELECTED if written else _EXIT_ERROR
212
+
213
+
214
+ if __name__ == "__main__":
215
+ raise SystemExit(main())
cdf/config.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from cdf.errors import CdfError
10
+
11
+ DEFAULT_IGNORE = ("node_modules", "__pycache__", ".venv")
12
+ _KNOWN_KEYS = {"path", "git", "hidden", "ignore", "max_depth", "one_file_system"}
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Config:
17
+ path: Path
18
+ git: bool = True
19
+ hidden: bool = False
20
+ ignore: tuple[str, ...] = field(default=DEFAULT_IGNORE)
21
+ max_depth: int | None = None
22
+ one_file_system: bool = False
23
+
24
+
25
+ def _home() -> Path | None:
26
+ # Path.home() raises when $HOME is unset and the user has no passwd entry
27
+ # (e.g. an arbitrary UID in a container); treat that as "no home".
28
+ try:
29
+ return Path.home()
30
+ except RuntimeError:
31
+ return None
32
+
33
+
34
+ def default_config_paths() -> tuple[Path, ...]:
35
+ home = _home()
36
+ paths: list[Path] = []
37
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
38
+ # The XDG spec says to ignore a relative XDG_CONFIG_HOME.
39
+ if xdg_config_home and Path(xdg_config_home).is_absolute():
40
+ paths.append(Path(xdg_config_home) / "cdf" / "cdf.conf")
41
+ elif home is not None:
42
+ paths.append(home / ".config" / "cdf" / "cdf.conf")
43
+ if home is not None:
44
+ paths.append(home / "cdf.conf")
45
+ return tuple(paths)
46
+
47
+
48
+ def _first_existing_default(paths: tuple[Path, ...]) -> Path | None:
49
+ # os.path.isfile, not Path.exists: on Python < 3.14 the latter raises
50
+ # PermissionError when a parent (e.g. ~/.config) can't be searched, and a
51
+ # file nobody can reach can't be the one the user meant.
52
+ return next((path for path in paths if os.path.isfile(path)), None)
53
+
54
+
55
+ def _absolute(path: Path | None) -> Path:
56
+ # Always hand an absolute root to the walker, so every printed result is
57
+ # absolute and the shell's `cd` never consults CDPATH or sees a leading "-".
58
+ # abspath (not resolve) keeps symlinked directories as the user wrote them.
59
+ try:
60
+ return Path(os.path.abspath(path)) if path is not None else Path.cwd()
61
+ except FileNotFoundError as exc:
62
+ raise CdfError(
63
+ "the current directory no longer exists; cd somewhere else or pass -p/--path"
64
+ ) from exc
65
+
66
+
67
+ def load_config_file(config_path: Path) -> dict[str, object]:
68
+ try:
69
+ with config_path.open("rb") as config_file:
70
+ data = tomllib.load(config_file)
71
+ except FileNotFoundError as exc:
72
+ # Default locations are only ever passed in when they exist, so a
73
+ # missing file here is always one the user named (-c or $CDF_CONFIG_PATH).
74
+ raise CdfError(f"config file not found: {config_path}") from exc
75
+ except tomllib.TOMLDecodeError as exc:
76
+ raise CdfError(f"invalid config file {config_path}: {exc}") from exc
77
+ except OSError as exc:
78
+ raise CdfError(f"cannot read config file {config_path}: {exc}") from exc
79
+
80
+ unknown = set(data) - _KNOWN_KEYS
81
+ if unknown:
82
+ raise CdfError(f"unknown key(s) in {config_path}: {', '.join(sorted(unknown))}")
83
+ return data
84
+
85
+
86
+ def _file_overrides(file_data: dict[str, object], config_path: Path) -> dict[str, Any]:
87
+ overrides: dict[str, Any] = {}
88
+
89
+ if "path" in file_data:
90
+ raw_path = file_data["path"]
91
+ if not isinstance(raw_path, str):
92
+ type_name = type(raw_path).__name__
93
+ raise CdfError(f"{config_path}: 'path' must be a string, got {type_name}")
94
+ # A relative path in the file means relative to the file, not to
95
+ # wherever cdf happens to be run from.
96
+ try:
97
+ expanded = Path(raw_path).expanduser()
98
+ except RuntimeError as exc:
99
+ raise CdfError(f"{config_path}: cannot expand '~' in 'path': {exc}") from exc
100
+ overrides["path"] = config_path.parent / expanded
101
+
102
+ for key in ("git", "hidden", "one_file_system"):
103
+ if key in file_data:
104
+ value = file_data[key]
105
+ if not isinstance(value, bool):
106
+ type_name = type(value).__name__
107
+ raise CdfError(f"{config_path}: '{key}' must be a boolean, got {type_name}")
108
+ overrides[key] = value
109
+
110
+ if "max_depth" in file_data:
111
+ value = file_data["max_depth"]
112
+ # bool is a subclass of int, so `max_depth = true` needs its own check.
113
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
114
+ raise CdfError(f"{config_path}: 'max_depth' must be a non-negative integer")
115
+ overrides["max_depth"] = value
116
+
117
+ if "ignore" in file_data:
118
+ patterns = file_data["ignore"]
119
+ if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns):
120
+ raise CdfError(f"{config_path}: 'ignore' must be a list of strings")
121
+ # Patterns are matched against one directory name at a time, so one
122
+ # with a "/" could never match; say so instead of silently ignoring it.
123
+ with_slash = [pattern for pattern in patterns if "/" in pattern]
124
+ if with_slash:
125
+ raise CdfError(
126
+ f"{config_path}: 'ignore' patterns match single directory names and "
127
+ f"can't contain '/': {', '.join(with_slash)}"
128
+ )
129
+ overrides["ignore"] = tuple(patterns)
130
+
131
+ return overrides
132
+
133
+
134
+ def _cli_overrides(
135
+ cli_path: Path | None,
136
+ cli_git: bool | None,
137
+ cli_hidden: bool | None,
138
+ cli_max_depth: int | None,
139
+ cli_one_file_system: bool | None,
140
+ ) -> dict[str, Any]:
141
+ candidates = {
142
+ "path": cli_path,
143
+ "git": cli_git,
144
+ "hidden": cli_hidden,
145
+ "max_depth": cli_max_depth,
146
+ "one_file_system": cli_one_file_system,
147
+ }
148
+ return {key: value for key, value in candidates.items() if value is not None}
149
+
150
+
151
+ def resolve_config(
152
+ *,
153
+ config_path: Path | None = None,
154
+ cli_path: Path | None = None,
155
+ cli_git: bool | None = None,
156
+ cli_hidden: bool | None = None,
157
+ cli_max_depth: int | None = None,
158
+ cli_one_file_system: bool | None = None,
159
+ ) -> Config:
160
+ resolved_config_path = config_path or _first_existing_default(default_config_paths())
161
+ file_overrides: dict[str, Any] = {}
162
+ if resolved_config_path is not None:
163
+ file_data = load_config_file(resolved_config_path)
164
+ file_overrides = _file_overrides(file_data, resolved_config_path)
165
+ cli_overrides = _cli_overrides(
166
+ cli_path, cli_git, cli_hidden, cli_max_depth, cli_one_file_system
167
+ )
168
+ overrides = {**file_overrides, **cli_overrides}
169
+ root = _absolute(overrides.pop("path", None))
170
+ # 0 means "no limit", so a CLI flag can lift a limit set in the file.
171
+ if overrides.get("max_depth") == 0:
172
+ overrides["max_depth"] = None
173
+ config = Config(path=root, **overrides)
174
+
175
+ if not config.path.is_dir():
176
+ raise CdfError(f"search root does not exist or is not a directory: {config.path}")
177
+
178
+ return config
cdf/display.py ADDED
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ # Unicode category Cc is exactly C0 (U+0000-U+001F), DEL and C1 (U+007F-U+009F).
4
+ # Any of them in a directory name could move the cursor, retitle the window or
5
+ # worse when echoed to a terminal, so they are shown as "?" instead.
6
+ _CONTROL_CHARS = {code: "?" for code in (*range(0x00, 0x20), *range(0x7F, 0xA0))}
7
+
8
+
9
+ def printable(text: str) -> str:
10
+ """Return `text` with terminal control characters replaced by "?".
11
+
12
+ Undecodable bytes (surrogate escapes) are left alone, so
13
+ `os.fsencode(printable(name))` still round-trips every other byte.
14
+ """
15
+ return text.translate(_CONTROL_CHARS)
cdf/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class CdfError(Exception):
5
+ pass
6
+
7
+
8
+ class NoCandidatesError(CdfError):
9
+ """The walk found nothing to pick from; not a failure, just an empty result."""
cdf/picker.py ADDED
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import threading
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from cdf.display import printable
12
+ from cdf.errors import CdfError
13
+
14
+ _EXIT_MATCHED = 0
15
+ _EXIT_NO_MATCH = 1
16
+ _EXIT_INTERRUPTED = 130
17
+
18
+
19
+ class Picker(Protocol):
20
+ def pick(self, candidates: Iterable[Path], term: str, *, root: Path) -> Path | None: ...
21
+
22
+
23
+ def _display(path: Path, root: Path) -> bytes:
24
+ # What fzf shows and matches: the path relative to the search root (so the
25
+ # root's own components don't match every query), with control characters
26
+ # (escape sequences, newlines, tabs) neutralised. Never used to resolve the
27
+ # pick except as a fallback; the index maps back to the exact original Path.
28
+ try:
29
+ shown = path.relative_to(root)
30
+ except ValueError:
31
+ shown = path
32
+ return os.fsencode(printable(os.fspath(shown)))
33
+
34
+
35
+ def _feed(
36
+ process: subprocess.Popen[bytes],
37
+ candidates: Iterable[Path],
38
+ root: Path,
39
+ seen: list[Path],
40
+ errors: list[Exception],
41
+ ) -> None:
42
+ # Each entry is "<index>\t<display>". fzf only shows/matches the display
43
+ # (see --with-nth) but echoes the whole entry back, so the index maps the
44
+ # pick to the exact original Path even when fzf can't round-trip its bytes
45
+ # (fzf replaces non-UTF-8 bytes in names). fzf closes its end as soon as the
46
+ # user picks something; a still-running walk then hits a broken pipe,
47
+ # which just means "stop feeding".
48
+ stdin = process.stdin
49
+ assert stdin is not None
50
+ try:
51
+ for candidate in candidates:
52
+ seen.append(candidate)
53
+ stdin.write(b"%d\t%s\0" % (len(seen) - 1, _display(candidate, root)))
54
+ except BrokenPipeError:
55
+ pass
56
+ except Exception as exc:
57
+ # Don't leave fzf showing a silently truncated list: stop it (before
58
+ # closing its input, so it can't finish normally) and let pick()
59
+ # report what went wrong.
60
+ errors.append(exc)
61
+ process.kill()
62
+ finally:
63
+ stdin.close()
64
+
65
+
66
+ def _parse_selection(output: bytes, seen: list[Path], root: Path) -> Path | None:
67
+ # FZF_DEFAULT_OPTS may add --print-query/--expect/--multi, which put extra
68
+ # NUL-delimited entries in the output. The selection is the last entry.
69
+ entries = [entry for entry in output.split(b"\0") if entry]
70
+ if not entries:
71
+ return None
72
+ entry = entries[-1]
73
+ selected = _by_index(entry, seen) or _by_display(entry, seen, root)
74
+ if selected is None:
75
+ raise CdfError(f"unexpected output from fzf: {entry!r}")
76
+ if not selected.is_dir():
77
+ raise CdfError(f"selected directory no longer exists: {selected}")
78
+ return selected
79
+
80
+
81
+ def _by_index(entry: bytes, seen: list[Path]) -> Path | None:
82
+ index, tab, _ = entry.partition(b"\t")
83
+ if not tab or not index.isdigit() or int(index) >= len(seen):
84
+ return None
85
+ return seen[int(index)]
86
+
87
+
88
+ def _by_display(entry: bytes, seen: list[Path], root: Path) -> Path | None:
89
+ # Fallback for an --accept-nth in FZF_DEFAULT_OPTS that prints only the
90
+ # display field. (Overriding it with --accept-nth ourselves would break
91
+ # fzf < 0.60, which rejects the option.) Displays never contain a tab, so
92
+ # this can't be confused with the index form; ambiguity is refused.
93
+ matches = [path for path in seen if _display(path, root) == entry]
94
+ return matches[0] if len(matches) == 1 else None
95
+
96
+
97
+ class FzfPicker:
98
+ def pick(self, candidates: Iterable[Path], term: str, *, root: Path) -> Path | None:
99
+ # Run exactly the fzf that was found, rather than searching PATH again.
100
+ fzf = shutil.which("fzf")
101
+ if fzf is None:
102
+ raise CdfError("fzf not found on PATH. Install it: https://github.com/junegunn/fzf")
103
+
104
+ # Unbuffered stdin so each candidate reaches fzf as soon as the walker
105
+ # finds it; stderr is inherited so fzf's own error messages show up.
106
+ process = subprocess.Popen(
107
+ [
108
+ fzf,
109
+ "--read0",
110
+ "--print0",
111
+ "--delimiter",
112
+ "\t",
113
+ "--with-nth",
114
+ "2..",
115
+ "--query",
116
+ term,
117
+ ],
118
+ stdin=subprocess.PIPE,
119
+ stdout=subprocess.PIPE,
120
+ bufsize=0,
121
+ )
122
+ assert process.stdin is not None and process.stdout is not None
123
+ seen: list[Path] = []
124
+ errors: list[Exception] = []
125
+ # Daemon thread: once fzf exits we return without waiting for the walk.
126
+ feeder = threading.Thread(
127
+ target=_feed, args=(process, candidates, root, seen, errors), daemon=True
128
+ )
129
+ feeder.start()
130
+
131
+ try:
132
+ output = process.stdout.read()
133
+ returncode = process.wait()
134
+ except BaseException:
135
+ process.kill()
136
+ process.wait()
137
+ raise
138
+
139
+ if returncode == _EXIT_MATCHED:
140
+ return _parse_selection(output, seen, root)
141
+ if errors:
142
+ raise CdfError(f"directory walk failed: {errors[0]}") from errors[0]
143
+ if returncode in (_EXIT_NO_MATCH, _EXIT_INTERRUPTED):
144
+ return None
145
+ raise CdfError(f"fzf exited with status {returncode}")
cdf/shell/cdf.fish ADDED
@@ -0,0 +1,30 @@
1
+ # cdf shell integration for fish. Load it with:
2
+ # command cdf --init fish | source
3
+ function cdf --description 'Interactively find a directory below here and cd into it'
4
+ for arg in $argv
5
+ switch $arg
6
+ case '--'
7
+ # Everything after it is search text.
8
+ break
9
+ case --help -h --version --init '--init=*'
10
+ command cdf $argv
11
+ return
12
+ end
13
+ end
14
+ set -l lines (command cdf $argv)
15
+ or return
16
+ # Command substitution splits on newlines and drops only the final one
17
+ # (the one cdf prints). Rejoin with variable operations: `string join` and
18
+ # `string collect` would add or trim newlines that belong to the name.
19
+ set -l target $lines[1]
20
+ for line in $lines[2..-1]
21
+ set target "$target"\n"$line"
22
+ end
23
+ # Only ever cd into a real directory; anything else (e.g. help text from
24
+ # combined short flags like -ah) is printed instead.
25
+ if test -d "$target"
26
+ cd -- $target
27
+ else if test -n "$target"
28
+ printf '%s\n' $target
29
+ end
30
+ end
cdf/shell/cdf.sh ADDED
@@ -0,0 +1,33 @@
1
+ # cdf shell integration for bash and zsh. Load it with:
2
+ # eval "$(command cdf --init bash)" # or: --init zsh
3
+ cdf() {
4
+ # Look at each argument on its own (not "$*", which depends on IFS and would
5
+ # also match a quoted search term like "foo -h bar"); stop at "--", after
6
+ # which everything is search text.
7
+ local arg
8
+ for arg in "$@"; do
9
+ case $arg in
10
+ --) break ;;
11
+ --help | -h | --version | --init | --init=*)
12
+ command cdf "$@"
13
+ return
14
+ ;;
15
+ esac
16
+ done
17
+ local target
18
+ # $(...) strips *all* trailing newlines, including ones that are part of
19
+ # the directory name, so append a "." and then remove it plus the single
20
+ # newline cdf prints after the path.
21
+ target="$(command cdf "$@" && printf .)" || return
22
+ target="${target%.}"
23
+ target="${target%?}"
24
+ # Only ever cd into a real directory; anything else (e.g. help text from
25
+ # combined short flags like -ah) is printed instead.
26
+ if [ -d "$target" ]; then
27
+ # \cd: bash/zsh expand aliases when this function is defined, so a plain
28
+ # `cd` would run e.g. `alias cd=z`. A cd *function* (hooks) still applies.
29
+ \cd -- "$target"
30
+ elif [ -n "$target" ]; then
31
+ printf '%s\n' "$target"
32
+ fi
33
+ }
cdf/walker.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Collection, Iterator
5
+ from fnmatch import fnmatchcase
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from cdf.errors import CdfError
10
+
11
+
12
+ class DirectoryWalker(Protocol):
13
+ def walk(
14
+ self,
15
+ root: Path,
16
+ *,
17
+ git_only: bool,
18
+ include_hidden: bool,
19
+ ignore: Collection[str],
20
+ max_depth: int | None = None,
21
+ one_file_system: bool = False,
22
+ ) -> Iterator[Path]: ...
23
+
24
+
25
+ def _is_pruned(name: str, *, include_hidden: bool, ignore: Collection[str]) -> bool:
26
+ if not include_hidden and name.startswith("."):
27
+ return True
28
+ return any(fnmatchcase(name, pattern) for pattern in ignore)
29
+
30
+
31
+ def _device(entry: os.DirEntry[str]) -> int:
32
+ return entry.stat(follow_symlinks=False).st_dev
33
+
34
+
35
+ class FilesystemWalker:
36
+ """Depth-first, sorted walk built directly on os.scandir.
37
+
38
+ scandir's DirEntry reports symlinks and directory-ness from the directory
39
+ listing itself, so unlike os.walk plus a per-directory lstat, detecting
40
+ symlinked directories costs no extra system calls.
41
+
42
+ `max_depth` counts levels below the root (1 = only its direct children).
43
+ With `one_file_system`, mount points below the root are listed but not
44
+ entered, like `find -xdev`; that costs one stat per kept subdirectory.
45
+ """
46
+
47
+ def walk(
48
+ self,
49
+ root: Path,
50
+ *,
51
+ git_only: bool,
52
+ include_hidden: bool,
53
+ ignore: Collection[str],
54
+ max_depth: int | None = None,
55
+ one_file_system: bool = False,
56
+ ) -> Iterator[Path]:
57
+ root_device: int | None = None
58
+ if one_file_system:
59
+ try:
60
+ root_device = os.stat(root).st_dev
61
+ except OSError as exc:
62
+ raise CdfError(f"cannot read search root {root}: {exc.strerror}") from exc
63
+ stack = [(root, 0)]
64
+ while stack:
65
+ current, depth = stack.pop()
66
+ # One cheap pass over the listing: is_dir(follow_symlinks=False) and
67
+ # is_symlink() come from the directory entry itself, so files (the
68
+ # vast majority) are dismissed without a syscall, prune check or sort.
69
+ is_repo = False
70
+ subdirs: list[os.DirEntry[str]] = []
71
+ links: list[os.DirEntry[str]] = []
72
+ try:
73
+ with os.scandir(current) as listing:
74
+ for entry in listing:
75
+ if entry.name == ".git":
76
+ # Marks a repo; never listed or descended into,
77
+ # whatever --all or the ignore patterns say.
78
+ is_repo = True
79
+ continue
80
+ try:
81
+ if entry.is_dir(follow_symlinks=False):
82
+ subdirs.append(entry)
83
+ elif entry.is_symlink():
84
+ links.append(entry)
85
+ except OSError:
86
+ # Only this entry is unreadable (e.g. a file system
87
+ # without entry types, needing a stat that fails);
88
+ # keep its siblings.
89
+ continue
90
+ except OSError as exc:
91
+ # Unreadable subdirectories are skipped (they can't be cd'd
92
+ # into anyway), but an unreadable root would otherwise
93
+ # masquerade as an empty tree.
94
+ if current == root:
95
+ raise CdfError(f"cannot read search root {root}: {exc.strerror}") from exc
96
+ continue
97
+
98
+ if current != root:
99
+ if git_only and is_repo:
100
+ yield current
101
+ continue
102
+ if not git_only:
103
+ yield current
104
+
105
+ if max_depth is not None and depth >= max_depth:
106
+ continue
107
+
108
+ kept = self._kept(subdirs, include_hidden=include_hidden, ignore=ignore)
109
+ leaves = self._kept(links, include_hidden=include_hidden, ignore=ignore)
110
+ if root_device is not None:
111
+ kept, mounts = self._split_mounts(kept, root_device)
112
+ leaves = sorted([*leaves, *mounts], key=lambda entry: entry.name)
113
+
114
+ for entry in leaves:
115
+ # Symlinks (so no loops) and, with one_file_system, mount points:
116
+ # never descended into, but still valid places to cd into, so
117
+ # offer them as leaves. is_dir() follows links, so broken links
118
+ # and links to files drop out.
119
+ path = Path(entry.path)
120
+ try:
121
+ if entry.is_dir() and (not git_only or (path / ".git").exists()):
122
+ yield path
123
+ except OSError:
124
+ continue
125
+
126
+ # Reversed so the alphabetically first subdirectory is popped next.
127
+ stack.extend((Path(entry.path), depth + 1) for entry in reversed(kept))
128
+
129
+ @staticmethod
130
+ def _split_mounts(
131
+ entries: list[os.DirEntry[str]], root_device: int
132
+ ) -> tuple[list[os.DirEntry[str]], list[os.DirEntry[str]]]:
133
+ same: list[os.DirEntry[str]] = []
134
+ mounts: list[os.DirEntry[str]] = []
135
+ for entry in entries:
136
+ try:
137
+ (same if _device(entry) == root_device else mounts).append(entry)
138
+ except OSError:
139
+ continue
140
+ return same, mounts
141
+
142
+ @staticmethod
143
+ def _kept(
144
+ entries: list[os.DirEntry[str]], *, include_hidden: bool, ignore: Collection[str]
145
+ ) -> list[os.DirEntry[str]]:
146
+ kept = [
147
+ entry
148
+ for entry in entries
149
+ if not _is_pruned(entry.name, include_hidden=include_hidden, ignore=ignore)
150
+ ]
151
+ kept.sort(key=lambda entry: entry.name)
152
+ return kept
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: cdf-shell
3
+ Version: 0.1.0
4
+ Summary: Interactive git-project cd/find
5
+ Author-email: Felipe Ruhland <pypi@feliperuhland.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+
11
+ # cdf
12
+
13
+ `cdf` ("cd" + "find") interactively searches the directory tree below your
14
+ current working directory for a term, and `cd`s into the directory you
15
+ select.
16
+
17
+ - Search scope is always the current working directory downward — never the
18
+ whole filesystem, never a cached/global index.
19
+ - By default it only matches git repository roots and stops descending once
20
+ it finds one (pass `--no-git` to walk every directory instead).
21
+ - Symlinked directories are listed (in git mode, only if they point at a
22
+ repository) but never descended into, so symlink loops can't hang the walk.
23
+ - Selection is powered by [`fzf`](https://github.com/junegunn/fzf) 0.44 or
24
+ newer, which must be on `PATH`. Your `FZF_DEFAULT_OPTS` (theme, layout,
25
+ `--print-query`, `--accept-nth`, ...) are respected.
26
+ - Results are shown (and fuzzy-matched) relative to the search root, so the
27
+ root's own path doesn't match every query.
28
+ - Directory names are shown with terminal control characters (escape
29
+ sequences, newlines, tabs) replaced by `?`, so a hostile name in a cloned
30
+ repo can't mess with your terminal; the directory you pick is still the
31
+ exact one on disk.
32
+
33
+ ## Install
34
+
35
+ ### From source
36
+
37
+ ```sh
38
+ uv sync
39
+ ```
40
+
41
+ This installs the `cdf` console script into `.venv/bin/`.
42
+
43
+ ### Arch Linux
44
+
45
+ A `PKGBUILD` is provided at the repo root, sourcing the tarball from this
46
+ repo's own `v*.*.*` git tags:
47
+
48
+ ```sh
49
+ makepkg -si
50
+ ```
51
+
52
+ `cdf` (the binary) only *resolves* a path and prints it to stdout — a Python
53
+ process can't change its parent shell's working directory. Load the shell
54
+ integration so the `cdf` command actually `cd`s for you:
55
+
56
+ ```sh
57
+ # ~/.bashrc
58
+ eval "$(command cdf --init bash)"
59
+ # ~/.zshrc
60
+ eval "$(command cdf --init zsh)"
61
+ ```
62
+
63
+ ```fish
64
+ # ~/.config/fish/config.fish
65
+ command cdf --init fish | source
66
+ ```
67
+
68
+ This defines a shell function also named `cdf`, which takes priority over
69
+ the binary in your shell and calls it internally via `command cdf`. Make
70
+ sure the `cdf` binary itself is on `PATH` (e.g. via `uv tool install .`, or
71
+ by adding `.venv/bin` to `PATH`).
72
+
73
+ ## Usage
74
+
75
+ ```sh
76
+ cdf [term ...] [-p/--path ROOT] [--git/--no-git] [-a/--all|--no-all] [-d/--max-depth N]
77
+ [-x/--one-file-system|--no-one-file-system] [-c/--config FILE]
78
+ cdf --init {bash,zsh,fish}
79
+ ```
80
+
81
+ | Flag | Default | Meaning |
82
+ |---|---|---|
83
+ | `term ...` | `""` | initial fuzzy filter text passed to `fzf` (several words are joined with spaces) |
84
+ | `-p, --path` | cwd | root to search from |
85
+ | `--git` / `--no-git` | `--git` | only match git repository roots |
86
+ | `-a, --all` / `--no-all` | `--no-all` | include hidden/dot directories |
87
+ | `-d, --max-depth N` | no limit | only look `N` levels below the root (`0` = no limit, to lift a limit set in the config file) |
88
+ | `-x, --one-file-system` / `--no-one-file-system` | off | list mount points but don't descend into them, like `find -xdev` |
89
+ | `-c, --config` | `$CDF_CONFIG_PATH`, else first existing default | config file path |
90
+ | `--init SHELL` | | print the shell integration for `bash`, `zsh` or `fish` and exit |
91
+
92
+ Run `cdf --help` for the full reference, or `cdf --version` (the shell
93
+ function special-cases `--help`/`-h`/`--version`/`--init` to pass them straight
94
+ through instead of attempting to `cd` into their output).
95
+
96
+ ### Exit codes
97
+
98
+ - `0` — a directory was selected
99
+ - `1` — nothing was selected (cancelled in `fzf`, or no candidates found, in
100
+ which case a hint is printed on stderr)
101
+ - `2` — an error occurred (message on stderr) — e.g. `fzf` missing, an
102
+ unreadable search root, or a bad config file
103
+ - `130` — interrupted with Ctrl-C
104
+
105
+ ## Config file
106
+
107
+ The config file path is resolved in this order:
108
+
109
+ 1. `-c/--config FILE` — must exist, or it's an error.
110
+ 2. `$CDF_CONFIG_PATH` — must exist, or it's an error.
111
+ 3. Otherwise, the first of these that exists (silently skipped if none do):
112
+ `$XDG_CONFIG_HOME/cdf/cdf.conf` (default `~/.config/cdf/cdf.conf`), then
113
+ `~/cdf.conf`.
114
+
115
+ All keys are optional (TOML syntax, despite the `.conf` extension):
116
+
117
+ ```toml
118
+ path = "~/projects" # string, expanded with ~; relative paths are relative to this file
119
+ git = true # boolean
120
+ hidden = false # boolean
121
+ ignore = ["node_modules", "__pycache__", ".venv"] # glob patterns of directory names to skip
122
+ max_depth = 0 # non-negative integer, 0 = no limit
123
+ one_file_system = false # boolean
124
+ ```
125
+
126
+ `ignore` replaces the default list shown above (set `ignore = []` to skip
127
+ nothing). Patterns match one directory name at a time, so they can't contain
128
+ `/`. `.git` directories are never descended into regardless.
129
+
130
+ `one_file_system` is off by default because btrfs subvolumes (and some other
131
+ setups) report their own device IDs, so turning it on would hide those
132
+ directories. Turn it on if you run `cdf --no-git` from places like `/` or `~`
133
+ that have network or FUSE mounts below them, which can be slow or hang.
134
+
135
+ Unknown keys, and values of the wrong type (e.g. `git = "false"` instead of
136
+ `git = false`), are rejected with an error, to catch typos early.
137
+
138
+ ## Development
139
+
140
+ ```sh
141
+ make run # uv run python -m cdf
142
+ make test # pytest + ruff + ruff-format + mypy (strict) + coverage, gated at 100%
143
+ make lint # uv run ruff check .
144
+ make build # sdist + wheel, with the build backend pinned by hash (build-constraints.txt)
145
+ make build-constraints # re-lock build-constraints.txt after editing build-constraints.in
146
+ ```
147
+
148
+ Supported Python: 3.11 through 3.14; CI runs the full `make test` gate on
149
+ each, with the bash/zsh/fish wrapper tests running against pinned zsh 5.9
150
+ and fish 4.9.3.
@@ -0,0 +1,16 @@
1
+ cdf/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cdf/__main__.py,sha256=FJykmAcI0-ihJECRdNvnp75UI77f2MbqclG_g7-efVM,122
3
+ cdf/app.py,sha256=-BPLXJlmFrt5pmuwUskcP-fJvf-kY3FSd3Mx-gks24M,1954
4
+ cdf/cli.py,sha256=RxRkMSuL5QZB9i4ScddGLh37RPGfKRnxYoT4HGHNSW4,6968
5
+ cdf/config.py,sha256=aV7K_8fdV6wRaGVXHy1ltWHNukfcKwDdQnO6Wps-Zt4,6832
6
+ cdf/display.py,sha256=QZ0iIp9soeHQnofy86qi7ZTLkqCr7gKYxrAEJYQEG4U,636
7
+ cdf/errors.py,sha256=9x2Y9BX_5Terv6z71CqA4OVtVb-hU1Y2ZicMdw_F6zY,194
8
+ cdf/picker.py,sha256=UaZ1qrasepiuojKCgqXSCSFg8LsTzDrLDxMMjH9GeEM,5287
9
+ cdf/walker.py,sha256=JhGeWDSUaN42RY_5AhGrocjx__qeJ-CAj-_4kilJGsw,5873
10
+ cdf/shell/cdf.fish,sha256=YjiUEp_yS5hx4Z8qX20OG_b2mmKEyU2146x3vZk3GGU,1079
11
+ cdf/shell/cdf.sh,sha256=bWc930bnaxeVBvbvkdrejxtDbi1QR3tWAvS6Uy5laqQ,1204
12
+ cdf_shell-0.1.0.dist-info/METADATA,sha256=p0m8xZniungDMYZUQsdOm-6aMmPiFW8EpZv7auQpPwI,5767
13
+ cdf_shell-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
14
+ cdf_shell-0.1.0.dist-info/entry_points.txt,sha256=ATccqID3dNdUxHOL9n2GS8jhLrxzaFOXLoZ-dwnAYmY,37
15
+ cdf_shell-0.1.0.dist-info/licenses/LICENSE,sha256=BME5hjhre9DaiLMUNKMsZEguxSTq48svKM-U4-opgIo,1071
16
+ cdf_shell-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cdf = cdf.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Ruhland
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.