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 +0 -0
- cdf/__main__.py +8 -0
- cdf/app.py +58 -0
- cdf/cli.py +215 -0
- cdf/config.py +178 -0
- cdf/display.py +15 -0
- cdf/errors.py +9 -0
- cdf/picker.py +145 -0
- cdf/shell/cdf.fish +30 -0
- cdf/shell/cdf.sh +33 -0
- cdf/walker.py +152 -0
- cdf_shell-0.1.0.dist-info/METADATA +150 -0
- cdf_shell-0.1.0.dist-info/RECORD +16 -0
- cdf_shell-0.1.0.dist-info/WHEEL +4 -0
- cdf_shell-0.1.0.dist-info/entry_points.txt +2 -0
- cdf_shell-0.1.0.dist-info/licenses/LICENSE +21 -0
cdf/__init__.py
ADDED
|
File without changes
|
cdf/__main__.py
ADDED
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
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,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.
|