filekeeper-cli 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.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
filekeeper/cli/app.py
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
"""The ``keeper`` command line.
|
|
2
|
+
|
|
3
|
+
The CLI parses arguments, calls Core, and renders. It never touches the
|
|
4
|
+
filesystem itself: every path decision and every write belongs to Core, so the
|
|
5
|
+
CLI and the future MCP server cannot drift apart in behaviour.
|
|
6
|
+
|
|
7
|
+
Line numbers accepted and printed here are 1-based and inclusive.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..core.errors import FilekeeperError, ValidationError
|
|
18
|
+
from ..core.service import Filekeeper, FilekeeperService
|
|
19
|
+
from . import exit_codes, output
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _add_common(parser: argparse.ArgumentParser, *, suppress: bool) -> None:
|
|
23
|
+
default = argparse.SUPPRESS if suppress else None
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--workspace",
|
|
26
|
+
metavar="NAME",
|
|
27
|
+
default=default,
|
|
28
|
+
help="workspace name or id (default: the configured default workspace)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--root",
|
|
32
|
+
metavar="PATH",
|
|
33
|
+
default=default,
|
|
34
|
+
help="operate on this directory as the workspace root",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--json",
|
|
38
|
+
action="store_true",
|
|
39
|
+
default=argparse.SUPPRESS if suppress else False,
|
|
40
|
+
help="emit one machine-readable JSON object",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--quiet",
|
|
44
|
+
action="store_true",
|
|
45
|
+
default=argparse.SUPPRESS if suppress else False,
|
|
46
|
+
help="suppress non-essential human output",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--verbose",
|
|
50
|
+
action="store_true",
|
|
51
|
+
default=argparse.SUPPRESS if suppress else False,
|
|
52
|
+
help="add detail to human output",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--home",
|
|
56
|
+
metavar="PATH",
|
|
57
|
+
default=default,
|
|
58
|
+
help="Filekeeper home directory (default: FILEKEEPER_HOME or the user data dir)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--actor",
|
|
62
|
+
metavar="NAME",
|
|
63
|
+
default=default,
|
|
64
|
+
help="identity recorded on a mutation, or used as a filter by history list",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--task-id",
|
|
68
|
+
metavar="ID",
|
|
69
|
+
default=default,
|
|
70
|
+
help="optional external task identifier recorded with a mutation",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
75
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
76
|
+
_add_common(common, suppress=True)
|
|
77
|
+
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
prog="keeper",
|
|
80
|
+
description=(
|
|
81
|
+
"Safe, workspace-confined filesystem operations. Line numbers are "
|
|
82
|
+
"1-based and inclusive. Every whole-file write is atomic."
|
|
83
|
+
),
|
|
84
|
+
epilog=(
|
|
85
|
+
"Exit codes: "
|
|
86
|
+
+ "; ".join(f"{code}={meaning}" for code, meaning in exit_codes.EXIT_CODE_DOCUMENTATION)
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
_add_common(parser, suppress=False)
|
|
90
|
+
parser.add_argument("--version", action="version", version=_version_string())
|
|
91
|
+
|
|
92
|
+
groups = parser.add_subparsers(dest="group", metavar="COMMAND", required=True)
|
|
93
|
+
|
|
94
|
+
init_parser = groups.add_parser(
|
|
95
|
+
"init", parents=[common], help="create the home directory and database"
|
|
96
|
+
)
|
|
97
|
+
init_parser.set_defaults(handler=_handle_init)
|
|
98
|
+
|
|
99
|
+
workspace = groups.add_parser(
|
|
100
|
+
"workspace", parents=[common], help="register and select workspaces"
|
|
101
|
+
)
|
|
102
|
+
workspace_sub = workspace.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
103
|
+
|
|
104
|
+
ws_create = workspace_sub.add_parser(
|
|
105
|
+
"create", parents=[common], help="register a directory as a workspace"
|
|
106
|
+
)
|
|
107
|
+
ws_create.add_argument("name")
|
|
108
|
+
ws_create.add_argument("root")
|
|
109
|
+
ws_create.set_defaults(handler=_handle_workspace_create)
|
|
110
|
+
|
|
111
|
+
ws_list = workspace_sub.add_parser("list", parents=[common], help="list workspaces")
|
|
112
|
+
ws_list.set_defaults(handler=_handle_workspace_list)
|
|
113
|
+
|
|
114
|
+
ws_show = workspace_sub.add_parser("show", parents=[common], help="show one workspace")
|
|
115
|
+
ws_show.add_argument("workspace_ref", metavar="NAME")
|
|
116
|
+
ws_show.set_defaults(handler=_handle_workspace_show)
|
|
117
|
+
|
|
118
|
+
ws_use = workspace_sub.add_parser("use", parents=[common], help="make a workspace the default")
|
|
119
|
+
ws_use.add_argument("workspace_ref", metavar="NAME")
|
|
120
|
+
ws_use.set_defaults(handler=_handle_workspace_use)
|
|
121
|
+
|
|
122
|
+
ws_remove = workspace_sub.add_parser(
|
|
123
|
+
"remove",
|
|
124
|
+
parents=[common],
|
|
125
|
+
help="remove a registration (never deletes files)",
|
|
126
|
+
)
|
|
127
|
+
ws_remove.add_argument("workspace_ref", metavar="NAME")
|
|
128
|
+
ws_remove.set_defaults(handler=_handle_workspace_remove)
|
|
129
|
+
|
|
130
|
+
file_group = groups.add_parser("file", parents=[common], help="file operations")
|
|
131
|
+
file_sub = file_group.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
132
|
+
|
|
133
|
+
read = file_sub.add_parser("read", parents=[common], help="read a file or line range")
|
|
134
|
+
read.add_argument("path")
|
|
135
|
+
read.add_argument("--from-line", type=int, dest="start_line", metavar="N")
|
|
136
|
+
read.add_argument("--to-line", type=int, dest="end_line", metavar="M")
|
|
137
|
+
read.add_argument("--max-bytes", type=int)
|
|
138
|
+
read.add_argument("--encoding")
|
|
139
|
+
read.set_defaults(handler=_handle_read)
|
|
140
|
+
|
|
141
|
+
read_many = file_sub.add_parser(
|
|
142
|
+
"read-many", parents=[common], help="read several files in one call"
|
|
143
|
+
)
|
|
144
|
+
read_many.add_argument("paths", nargs="+")
|
|
145
|
+
read_many.add_argument("--max-bytes-per-file", type=int)
|
|
146
|
+
read_many.add_argument("--from-line", type=int, dest="start_line", metavar="N")
|
|
147
|
+
read_many.add_argument("--to-line", type=int, dest="end_line", metavar="M")
|
|
148
|
+
read_many.set_defaults(handler=_handle_read_many)
|
|
149
|
+
|
|
150
|
+
info = file_sub.add_parser("info", parents=[common], help="show metadata for a path")
|
|
151
|
+
info.add_argument("path")
|
|
152
|
+
info.set_defaults(handler=_handle_info)
|
|
153
|
+
|
|
154
|
+
hash_parser = file_sub.add_parser("hash", parents=[common], help="print a file's SHA-256")
|
|
155
|
+
hash_parser.add_argument("path")
|
|
156
|
+
hash_parser.set_defaults(handler=_handle_hash)
|
|
157
|
+
|
|
158
|
+
list_parser = file_sub.add_parser("list", parents=[common], help="list a directory")
|
|
159
|
+
list_parser.add_argument("path", nargs="?", default=".")
|
|
160
|
+
list_parser.add_argument("--recursive", action="store_true")
|
|
161
|
+
list_parser.add_argument("--glob", metavar="PATTERN")
|
|
162
|
+
list_parser.add_argument("--max-entries", type=int)
|
|
163
|
+
list_parser.set_defaults(handler=_handle_list)
|
|
164
|
+
|
|
165
|
+
exists_parser = file_sub.add_parser(
|
|
166
|
+
"exists", parents=[common], help="report whether a path exists"
|
|
167
|
+
)
|
|
168
|
+
exists_parser.add_argument("path")
|
|
169
|
+
exists_parser.set_defaults(handler=_handle_exists)
|
|
170
|
+
|
|
171
|
+
search = file_sub.add_parser("search", parents=[common], help="find paths matching a glob")
|
|
172
|
+
search.add_argument("path")
|
|
173
|
+
search.add_argument("pattern", metavar="GLOB")
|
|
174
|
+
search.add_argument("--max-results", type=int)
|
|
175
|
+
search.set_defaults(handler=_handle_search_paths)
|
|
176
|
+
|
|
177
|
+
grep = file_sub.add_parser(
|
|
178
|
+
"grep", parents=[common], help="search file contents (literal or --regex)"
|
|
179
|
+
)
|
|
180
|
+
grep.add_argument("path")
|
|
181
|
+
grep.add_argument("query")
|
|
182
|
+
grep.add_argument("--regex", action="store_true")
|
|
183
|
+
grep.add_argument("--glob", metavar="PATTERN")
|
|
184
|
+
grep.add_argument("--ignore-case", action="store_true")
|
|
185
|
+
grep.add_argument("--max-results", type=int)
|
|
186
|
+
grep.set_defaults(handler=_handle_grep)
|
|
187
|
+
|
|
188
|
+
for name, handler, help_text in (
|
|
189
|
+
("create", _handle_create, "create a file (fails if it exists)"),
|
|
190
|
+
("write", _handle_write, "replace a file's content"),
|
|
191
|
+
):
|
|
192
|
+
mutation = file_sub.add_parser(name, parents=[common], help=help_text)
|
|
193
|
+
mutation.add_argument("path")
|
|
194
|
+
mutation.add_argument("--content", help="content as an argument")
|
|
195
|
+
mutation.add_argument("--input", metavar="FILE", help="read content from this file")
|
|
196
|
+
mutation.add_argument("--encoding")
|
|
197
|
+
mutation.add_argument("--dry-run", action="store_true")
|
|
198
|
+
mutation.add_argument(
|
|
199
|
+
"--i-accept-secret-write-risk",
|
|
200
|
+
action="store_true",
|
|
201
|
+
dest="allow_sensitive_write",
|
|
202
|
+
help="permit writing credential-like paths (.env, id_rsa, *.pem, ...)",
|
|
203
|
+
)
|
|
204
|
+
if name == "create":
|
|
205
|
+
mutation.add_argument(
|
|
206
|
+
"--allow-existing",
|
|
207
|
+
action="store_true",
|
|
208
|
+
help="overwrite an existing file instead of failing",
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
mutation.add_argument("--expected-hash", metavar="HASH")
|
|
212
|
+
mutation.set_defaults(handler=handler)
|
|
213
|
+
|
|
214
|
+
copy = file_sub.add_parser("copy", parents=[common], help="copy a file")
|
|
215
|
+
copy.add_argument("source")
|
|
216
|
+
copy.add_argument("destination")
|
|
217
|
+
copy.add_argument("--expected-destination-hash", metavar="HASH")
|
|
218
|
+
copy.add_argument("--dry-run", action="store_true")
|
|
219
|
+
copy.add_argument(
|
|
220
|
+
"--i-accept-secret-write-risk",
|
|
221
|
+
action="store_true",
|
|
222
|
+
dest="allow_sensitive_write",
|
|
223
|
+
)
|
|
224
|
+
copy.set_defaults(handler=_handle_copy)
|
|
225
|
+
|
|
226
|
+
move = file_sub.add_parser("move", parents=[common], help="move or rename a file")
|
|
227
|
+
move.add_argument("source")
|
|
228
|
+
move.add_argument("destination")
|
|
229
|
+
move.add_argument("--overwrite", action="store_true")
|
|
230
|
+
move.add_argument("--dry-run", action="store_true")
|
|
231
|
+
move.add_argument(
|
|
232
|
+
"--i-accept-secret-write-risk",
|
|
233
|
+
action="store_true",
|
|
234
|
+
dest="allow_sensitive_write",
|
|
235
|
+
)
|
|
236
|
+
move.set_defaults(handler=_handle_move)
|
|
237
|
+
|
|
238
|
+
delete = file_sub.add_parser("delete", parents=[common], help="delete a file")
|
|
239
|
+
delete.add_argument("path")
|
|
240
|
+
delete.add_argument("--expected-hash", metavar="HASH")
|
|
241
|
+
delete.add_argument("--dry-run", action="store_true")
|
|
242
|
+
delete.set_defaults(handler=_handle_delete)
|
|
243
|
+
|
|
244
|
+
replace_text_parser = file_sub.add_parser(
|
|
245
|
+
"replace-text",
|
|
246
|
+
parents=[common],
|
|
247
|
+
help="replace literal text, reporting the matching stage",
|
|
248
|
+
)
|
|
249
|
+
replace_text_parser.add_argument("path")
|
|
250
|
+
replace_text_parser.add_argument("--old", required=True, metavar="TEXT")
|
|
251
|
+
replace_text_parser.add_argument("--new", required=True, metavar="TEXT")
|
|
252
|
+
policy = replace_text_parser.add_mutually_exclusive_group(required=True)
|
|
253
|
+
policy.add_argument("--first", action="store_const", const="first", dest="match")
|
|
254
|
+
policy.add_argument("--all", action="store_const", const="all", dest="match")
|
|
255
|
+
replace_text_parser.add_argument("--expected-hash", metavar="HASH")
|
|
256
|
+
replace_text_parser.add_argument("--expected-content", metavar="TEXT")
|
|
257
|
+
replace_text_parser.add_argument("--expected-match-count", type=int, metavar="N")
|
|
258
|
+
replace_text_parser.add_argument("--dry-run", action="store_true")
|
|
259
|
+
replace_text_parser.add_argument(
|
|
260
|
+
"--i-accept-secret-write-risk",
|
|
261
|
+
action="store_true",
|
|
262
|
+
dest="allow_sensitive_write",
|
|
263
|
+
)
|
|
264
|
+
replace_text_parser.set_defaults(handler=_handle_replace_text)
|
|
265
|
+
|
|
266
|
+
replace_lines_parser = file_sub.add_parser(
|
|
267
|
+
"replace-lines", parents=[common], help="replace an inclusive 1-based line range"
|
|
268
|
+
)
|
|
269
|
+
replace_lines_parser.add_argument("path")
|
|
270
|
+
replace_lines_parser.add_argument("--start", type=int, required=True, dest="start_line")
|
|
271
|
+
replace_lines_parser.add_argument("--end", type=int, required=True, dest="end_line")
|
|
272
|
+
replace_lines_parser.add_argument("--replacement", required=True, metavar="TEXT")
|
|
273
|
+
replace_lines_parser.add_argument("--expected-content", metavar="TEXT")
|
|
274
|
+
replace_lines_parser.add_argument("--expected-hash", metavar="HASH")
|
|
275
|
+
replace_lines_parser.add_argument("--dry-run", action="store_true")
|
|
276
|
+
replace_lines_parser.set_defaults(handler=_handle_replace_lines)
|
|
277
|
+
|
|
278
|
+
insert_lines_parser = file_sub.add_parser(
|
|
279
|
+
"insert-lines", parents=[common], help="insert lines before, after or at the end"
|
|
280
|
+
)
|
|
281
|
+
insert_lines_parser.add_argument("path")
|
|
282
|
+
insert_lines_parser.add_argument("--line", type=int, metavar="N")
|
|
283
|
+
insert_lines_parser.add_argument(
|
|
284
|
+
"--position", choices=["before", "after", "end"], required=True
|
|
285
|
+
)
|
|
286
|
+
insert_lines_parser.add_argument("--content", required=True, metavar="TEXT")
|
|
287
|
+
insert_lines_parser.add_argument("--expected-content", metavar="TEXT")
|
|
288
|
+
insert_lines_parser.add_argument("--expected-hash", metavar="HASH")
|
|
289
|
+
insert_lines_parser.add_argument("--dry-run", action="store_true")
|
|
290
|
+
insert_lines_parser.set_defaults(handler=_handle_insert_lines)
|
|
291
|
+
|
|
292
|
+
delete_lines_parser = file_sub.add_parser(
|
|
293
|
+
"delete-lines", parents=[common], help="delete an inclusive 1-based line range"
|
|
294
|
+
)
|
|
295
|
+
delete_lines_parser.add_argument("path")
|
|
296
|
+
delete_lines_parser.add_argument("--start", type=int, required=True, dest="start_line")
|
|
297
|
+
delete_lines_parser.add_argument("--end", type=int, required=True, dest="end_line")
|
|
298
|
+
delete_lines_parser.add_argument("--expected-content", metavar="TEXT")
|
|
299
|
+
delete_lines_parser.add_argument("--expected-hash", metavar="HASH")
|
|
300
|
+
delete_lines_parser.add_argument("--dry-run", action="store_true")
|
|
301
|
+
delete_lines_parser.set_defaults(handler=_handle_delete_lines)
|
|
302
|
+
|
|
303
|
+
apply_patch_parser = file_sub.add_parser(
|
|
304
|
+
"apply-patch",
|
|
305
|
+
parents=[common],
|
|
306
|
+
help="apply a unified diff, recoverably across files",
|
|
307
|
+
)
|
|
308
|
+
apply_patch_parser.add_argument("patch_file", nargs="?", metavar="PATCH")
|
|
309
|
+
apply_patch_parser.add_argument("--dry-run", action="store_true")
|
|
310
|
+
apply_patch_parser.add_argument(
|
|
311
|
+
"--i-accept-secret-write-risk",
|
|
312
|
+
action="store_true",
|
|
313
|
+
dest="allow_sensitive_write",
|
|
314
|
+
)
|
|
315
|
+
apply_patch_parser.set_defaults(handler=_handle_apply_patch)
|
|
316
|
+
|
|
317
|
+
directory_group = groups.add_parser("dir", parents=[common], help="directory operations")
|
|
318
|
+
directory_sub = directory_group.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
319
|
+
|
|
320
|
+
dir_create = directory_sub.add_parser("create", parents=[common], help="create a directory")
|
|
321
|
+
dir_create.add_argument("path")
|
|
322
|
+
dir_create.add_argument("--parents", action="store_true")
|
|
323
|
+
dir_create.add_argument("--dry-run", action="store_true")
|
|
324
|
+
dir_create.set_defaults(handler=_handle_dir_create)
|
|
325
|
+
|
|
326
|
+
dir_move = directory_sub.add_parser("move", parents=[common], help="move a directory")
|
|
327
|
+
dir_move.add_argument("source")
|
|
328
|
+
dir_move.add_argument("destination")
|
|
329
|
+
dir_move.add_argument("--dry-run", action="store_true")
|
|
330
|
+
dir_move.set_defaults(handler=_handle_dir_move)
|
|
331
|
+
|
|
332
|
+
dir_delete = directory_sub.add_parser("delete", parents=[common], help="delete a directory")
|
|
333
|
+
dir_delete.add_argument("path")
|
|
334
|
+
dir_delete.add_argument("--recursive", action="store_true")
|
|
335
|
+
dir_delete.add_argument(
|
|
336
|
+
"--confirm-recursive",
|
|
337
|
+
action="store_true",
|
|
338
|
+
help="required together with --recursive: acknowledge that a tree will be deleted",
|
|
339
|
+
)
|
|
340
|
+
dir_delete.add_argument("--dry-run", action="store_true")
|
|
341
|
+
dir_delete.set_defaults(handler=_handle_dir_delete)
|
|
342
|
+
|
|
343
|
+
history = groups.add_parser("history", parents=[common], help="operation history and undo")
|
|
344
|
+
history_sub = history.add_subparsers(dest="action", metavar="ACTION", required=True)
|
|
345
|
+
|
|
346
|
+
history_list = history_sub.add_parser(
|
|
347
|
+
"list", parents=[common], help="list recorded operations, newest first"
|
|
348
|
+
)
|
|
349
|
+
history_list.add_argument("--path", metavar="PATH", help="only operations touching this path")
|
|
350
|
+
history_list.add_argument("--limit", type=int, default=50, metavar="N")
|
|
351
|
+
history_list.set_defaults(handler=_handle_history_list)
|
|
352
|
+
|
|
353
|
+
history_show = history_sub.add_parser(
|
|
354
|
+
"show", parents=[common], help="show one operation and its affected paths"
|
|
355
|
+
)
|
|
356
|
+
history_show.add_argument("operation_id")
|
|
357
|
+
history_show.set_defaults(handler=_handle_history_show)
|
|
358
|
+
|
|
359
|
+
history_diff = history_sub.add_parser(
|
|
360
|
+
"diff", parents=[common], help="print the unified diff an operation made"
|
|
361
|
+
)
|
|
362
|
+
history_diff.add_argument("operation_id")
|
|
363
|
+
history_diff.set_defaults(handler=_handle_history_diff)
|
|
364
|
+
|
|
365
|
+
history_undo = history_sub.add_parser(
|
|
366
|
+
"undo", parents=[common], help="restore the state before one operation"
|
|
367
|
+
)
|
|
368
|
+
history_undo.add_argument("operation_id")
|
|
369
|
+
history_undo.set_defaults(handler=_handle_history_undo)
|
|
370
|
+
|
|
371
|
+
history_undo_last = history_sub.add_parser(
|
|
372
|
+
"undo-last", parents=[common], help="undo the most recent operation in this workspace"
|
|
373
|
+
)
|
|
374
|
+
history_undo_last.set_defaults(handler=_handle_history_undo_last)
|
|
375
|
+
|
|
376
|
+
return parser
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _version_string() -> str:
|
|
380
|
+
from .. import __version__
|
|
381
|
+
|
|
382
|
+
return f"keeper {__version__}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class _Context:
|
|
386
|
+
"""Resolved options and lazily constructed Core objects."""
|
|
387
|
+
|
|
388
|
+
def __init__(self, args: argparse.Namespace) -> None:
|
|
389
|
+
self.args = args
|
|
390
|
+
self.as_json: bool = bool(getattr(args, "json", False))
|
|
391
|
+
self.quiet: bool = bool(getattr(args, "quiet", False))
|
|
392
|
+
self.home = getattr(args, "home", None)
|
|
393
|
+
self._app: Filekeeper | None = None
|
|
394
|
+
self._service: FilekeeperService | None = None
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def app(self) -> Filekeeper:
|
|
398
|
+
if self._app is None:
|
|
399
|
+
self._app = Filekeeper(home=self.home)
|
|
400
|
+
return self._app
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def service(self) -> FilekeeperService:
|
|
404
|
+
if self._service is None:
|
|
405
|
+
self._service = self.app.workspace(
|
|
406
|
+
name=getattr(self.args, "workspace", None),
|
|
407
|
+
root=getattr(self.args, "root", None),
|
|
408
|
+
)
|
|
409
|
+
return self._service
|
|
410
|
+
|
|
411
|
+
def emit(self, payload: dict[str, object], text: str) -> None:
|
|
412
|
+
if self.as_json:
|
|
413
|
+
output.print_json(payload)
|
|
414
|
+
elif text:
|
|
415
|
+
sys.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _content_from_args(args: argparse.Namespace) -> str:
|
|
419
|
+
content = getattr(args, "content", None)
|
|
420
|
+
if content is not None:
|
|
421
|
+
return str(content)
|
|
422
|
+
input_path = getattr(args, "input", None)
|
|
423
|
+
if input_path is not None:
|
|
424
|
+
try:
|
|
425
|
+
return Path(input_path).read_text(encoding="utf-8")
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
raise ValidationError(
|
|
428
|
+
f"cannot read content file: {exc}", details={"input": str(input_path)}
|
|
429
|
+
) from exc
|
|
430
|
+
except UnicodeDecodeError as exc:
|
|
431
|
+
raise ValidationError(
|
|
432
|
+
f"content file is not UTF-8 text: {exc}", details={"input": str(input_path)}
|
|
433
|
+
) from exc
|
|
434
|
+
if not sys.stdin.isatty():
|
|
435
|
+
return sys.stdin.read()
|
|
436
|
+
raise ValidationError("provide --content, --input FILE, or content on standard input")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _handle_init(context: _Context) -> int:
|
|
440
|
+
home = context.app.init()
|
|
441
|
+
context.emit(
|
|
442
|
+
{"ok": True, "home": str(home)},
|
|
443
|
+
f"initialized Filekeeper home at {home}",
|
|
444
|
+
)
|
|
445
|
+
return exit_codes.SUCCESS
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _handle_workspace_create(context: _Context) -> int:
|
|
449
|
+
workspace = context.app.create_workspace(context.args.name, context.args.root)
|
|
450
|
+
context.emit(
|
|
451
|
+
{"ok": True, "workspace": output.jsonable(workspace)},
|
|
452
|
+
f"registered workspace {workspace.name} at {workspace.root_path} ({workspace.id})\n"
|
|
453
|
+
"state directory .filekeeper/ is created on the first mutation, together with "
|
|
454
|
+
"a .gitignore entry when this directory is a git repository",
|
|
455
|
+
)
|
|
456
|
+
return exit_codes.SUCCESS
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _handle_workspace_list(context: _Context) -> int:
|
|
460
|
+
workspaces = context.app.list_workspaces()
|
|
461
|
+
default_name = context.app.registry.get_default_name()
|
|
462
|
+
lines = [
|
|
463
|
+
output.workspace_summary(workspace, is_default=workspace.name == default_name)
|
|
464
|
+
for workspace in workspaces
|
|
465
|
+
]
|
|
466
|
+
context.emit(
|
|
467
|
+
{
|
|
468
|
+
"ok": True,
|
|
469
|
+
"default_workspace": default_name,
|
|
470
|
+
"workspaces": output.jsonable(workspaces),
|
|
471
|
+
},
|
|
472
|
+
"\n".join(lines) if lines else "no workspaces registered",
|
|
473
|
+
)
|
|
474
|
+
return exit_codes.SUCCESS
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _handle_workspace_show(context: _Context) -> int:
|
|
478
|
+
workspace = context.app.get_workspace(context.args.workspace_ref)
|
|
479
|
+
is_default = context.app.registry.get_default_name() == workspace.name
|
|
480
|
+
context.emit(
|
|
481
|
+
{
|
|
482
|
+
"ok": True,
|
|
483
|
+
"workspace": output.jsonable(workspace),
|
|
484
|
+
"default": is_default,
|
|
485
|
+
"state_dir": str(context.app.registry.state_dir_for(workspace)),
|
|
486
|
+
},
|
|
487
|
+
output.workspace_detail(workspace, is_default=is_default),
|
|
488
|
+
)
|
|
489
|
+
return exit_codes.SUCCESS
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _handle_workspace_use(context: _Context) -> int:
|
|
493
|
+
workspace = context.app.use_workspace(context.args.workspace_ref)
|
|
494
|
+
context.emit(
|
|
495
|
+
{"ok": True, "default_workspace": workspace.name},
|
|
496
|
+
f"default workspace is now {workspace.name}",
|
|
497
|
+
)
|
|
498
|
+
return exit_codes.SUCCESS
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _handle_workspace_remove(context: _Context) -> int:
|
|
502
|
+
workspace = context.app.remove_workspace(context.args.workspace_ref)
|
|
503
|
+
context.emit(
|
|
504
|
+
{"ok": True, "removed": output.jsonable(workspace)},
|
|
505
|
+
f"removed registration for {workspace.name}; its files were not touched",
|
|
506
|
+
)
|
|
507
|
+
return exit_codes.SUCCESS
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _handle_read(context: _Context) -> int:
|
|
511
|
+
result = context.service.read_file(
|
|
512
|
+
context.args.path,
|
|
513
|
+
start_line=context.args.start_line,
|
|
514
|
+
end_line=context.args.end_line,
|
|
515
|
+
max_bytes=context.args.max_bytes,
|
|
516
|
+
encoding=context.args.encoding,
|
|
517
|
+
)
|
|
518
|
+
if context.as_json:
|
|
519
|
+
output.print_json(output.result_payload(result))
|
|
520
|
+
else:
|
|
521
|
+
sys.stdout.write(result.content)
|
|
522
|
+
if result.truncated and not context.quiet:
|
|
523
|
+
sys.stderr.write(
|
|
524
|
+
f"note: {result.truncation_reason}; resume at line {result.next_start_line}\n"
|
|
525
|
+
)
|
|
526
|
+
return exit_codes.SUCCESS
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _handle_read_many(context: _Context) -> int:
|
|
530
|
+
entries = context.service.read_multiple_files(
|
|
531
|
+
context.args.paths,
|
|
532
|
+
max_bytes_per_file=context.args.max_bytes_per_file,
|
|
533
|
+
start_line=context.args.start_line,
|
|
534
|
+
end_line=context.args.end_line,
|
|
535
|
+
)
|
|
536
|
+
context.emit(
|
|
537
|
+
{"ok": all(entry.ok for entry in entries), "files": output.jsonable(entries)},
|
|
538
|
+
output.read_many(entries),
|
|
539
|
+
)
|
|
540
|
+
failure = next((entry for entry in entries if not entry.ok), None)
|
|
541
|
+
if failure is None:
|
|
542
|
+
return exit_codes.SUCCESS
|
|
543
|
+
# A per-file failure is data, but the process exit code still tells the truth
|
|
544
|
+
# about the first failure's kind (e.g. an unreadable file is a storage error).
|
|
545
|
+
if failure.error is None: # pragma: no cover - every failure carries a detail
|
|
546
|
+
return exit_codes.GENERAL_ERROR
|
|
547
|
+
return exit_codes.exit_code_for_code(failure.error.code)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _handle_info(context: _Context) -> int:
|
|
551
|
+
result = context.service.get_file_info(context.args.path)
|
|
552
|
+
context.emit(output.result_payload(result), output.info(result))
|
|
553
|
+
return exit_codes.SUCCESS
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _handle_hash(context: _Context) -> int:
|
|
557
|
+
digest = context.service.get_file_hash(context.args.path)
|
|
558
|
+
context.emit({"ok": True, "path": context.args.path, "content_hash": digest}, digest)
|
|
559
|
+
return exit_codes.SUCCESS
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _handle_list(context: _Context) -> int:
|
|
563
|
+
result = context.service.list_directory(
|
|
564
|
+
context.args.path,
|
|
565
|
+
recursive=context.args.recursive,
|
|
566
|
+
glob=context.args.glob,
|
|
567
|
+
max_entries=context.args.max_entries,
|
|
568
|
+
)
|
|
569
|
+
context.emit(output.result_payload(result), output.listing(result))
|
|
570
|
+
return exit_codes.SUCCESS
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _handle_exists(context: _Context) -> int:
|
|
574
|
+
result = context.service.file_exists(context.args.path)
|
|
575
|
+
context.emit(output.result_payload(result), output.exists(result))
|
|
576
|
+
return exit_codes.SUCCESS if result.exists else exit_codes.PATH_NOT_FOUND
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _handle_search_paths(context: _Context) -> int:
|
|
580
|
+
result = context.service.search_files(
|
|
581
|
+
context.args.path, context.args.pattern, max_results=context.args.max_results
|
|
582
|
+
)
|
|
583
|
+
context.emit(output.result_payload(result), output.path_search(result))
|
|
584
|
+
return exit_codes.SUCCESS
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _handle_grep(context: _Context) -> int:
|
|
588
|
+
result = context.service.search_content(
|
|
589
|
+
context.args.path,
|
|
590
|
+
context.args.query,
|
|
591
|
+
regex=context.args.regex,
|
|
592
|
+
glob=context.args.glob,
|
|
593
|
+
max_results=context.args.max_results,
|
|
594
|
+
case_sensitive=not context.args.ignore_case,
|
|
595
|
+
)
|
|
596
|
+
context.emit(output.result_payload(result), output.content_search(result))
|
|
597
|
+
return exit_codes.SUCCESS
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _emit_mutation(context: _Context, result: object) -> int:
|
|
601
|
+
text = output.mutation(result) # type: ignore[arg-type]
|
|
602
|
+
context.emit(output.result_payload(result), text)
|
|
603
|
+
return exit_codes.SUCCESS
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _handle_create(context: _Context) -> int:
|
|
607
|
+
result = context.service.create_file(
|
|
608
|
+
context.args.path,
|
|
609
|
+
_content_from_args(context.args),
|
|
610
|
+
expected_absent=not context.args.allow_existing,
|
|
611
|
+
encoding=context.args.encoding or "utf-8",
|
|
612
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
613
|
+
dry_run=context.args.dry_run,
|
|
614
|
+
actor=getattr(context.args, "actor", None),
|
|
615
|
+
task_id=getattr(context.args, "task_id", None),
|
|
616
|
+
)
|
|
617
|
+
return _emit_mutation(context, result)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _handle_write(context: _Context) -> int:
|
|
621
|
+
result = context.service.write_file(
|
|
622
|
+
context.args.path,
|
|
623
|
+
_content_from_args(context.args),
|
|
624
|
+
expected_hash=context.args.expected_hash,
|
|
625
|
+
encoding=context.args.encoding,
|
|
626
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
627
|
+
dry_run=context.args.dry_run,
|
|
628
|
+
actor=getattr(context.args, "actor", None),
|
|
629
|
+
task_id=getattr(context.args, "task_id", None),
|
|
630
|
+
)
|
|
631
|
+
return _emit_mutation(context, result)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _handle_copy(context: _Context) -> int:
|
|
635
|
+
result = context.service.copy_file(
|
|
636
|
+
context.args.source,
|
|
637
|
+
context.args.destination,
|
|
638
|
+
expected_destination_hash=context.args.expected_destination_hash,
|
|
639
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
640
|
+
dry_run=context.args.dry_run,
|
|
641
|
+
actor=getattr(context.args, "actor", None),
|
|
642
|
+
task_id=getattr(context.args, "task_id", None),
|
|
643
|
+
)
|
|
644
|
+
return _emit_mutation(context, result)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _handle_move(context: _Context) -> int:
|
|
648
|
+
result = context.service.move_file(
|
|
649
|
+
context.args.source,
|
|
650
|
+
context.args.destination,
|
|
651
|
+
overwrite=context.args.overwrite,
|
|
652
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
653
|
+
dry_run=context.args.dry_run,
|
|
654
|
+
actor=getattr(context.args, "actor", None),
|
|
655
|
+
task_id=getattr(context.args, "task_id", None),
|
|
656
|
+
)
|
|
657
|
+
return _emit_mutation(context, result)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _handle_delete(context: _Context) -> int:
|
|
661
|
+
result = context.service.delete_file(
|
|
662
|
+
context.args.path,
|
|
663
|
+
expected_hash=context.args.expected_hash,
|
|
664
|
+
dry_run=context.args.dry_run,
|
|
665
|
+
actor=getattr(context.args, "actor", None),
|
|
666
|
+
task_id=getattr(context.args, "task_id", None),
|
|
667
|
+
)
|
|
668
|
+
return _emit_mutation(context, result)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _handle_replace_text(context: _Context) -> int:
|
|
672
|
+
result = context.service.replace_text(
|
|
673
|
+
context.args.path,
|
|
674
|
+
context.args.old,
|
|
675
|
+
context.args.new,
|
|
676
|
+
match=context.args.match,
|
|
677
|
+
expected_hash=context.args.expected_hash,
|
|
678
|
+
expected_content=context.args.expected_content,
|
|
679
|
+
expected_match_count=context.args.expected_match_count,
|
|
680
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
681
|
+
dry_run=context.args.dry_run,
|
|
682
|
+
actor=getattr(context.args, "actor", None),
|
|
683
|
+
task_id=getattr(context.args, "task_id", None),
|
|
684
|
+
)
|
|
685
|
+
return _emit_mutation(context, result)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _handle_replace_lines(context: _Context) -> int:
|
|
689
|
+
result = context.service.replace_lines(
|
|
690
|
+
context.args.path,
|
|
691
|
+
context.args.start_line,
|
|
692
|
+
context.args.end_line,
|
|
693
|
+
context.args.replacement,
|
|
694
|
+
expected_content=context.args.expected_content,
|
|
695
|
+
expected_hash=context.args.expected_hash,
|
|
696
|
+
dry_run=context.args.dry_run,
|
|
697
|
+
actor=getattr(context.args, "actor", None),
|
|
698
|
+
task_id=getattr(context.args, "task_id", None),
|
|
699
|
+
)
|
|
700
|
+
return _emit_mutation(context, result)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _handle_insert_lines(context: _Context) -> int:
|
|
704
|
+
result = context.service.insert_lines(
|
|
705
|
+
context.args.path,
|
|
706
|
+
context.args.line,
|
|
707
|
+
context.args.content,
|
|
708
|
+
position=context.args.position,
|
|
709
|
+
expected_content=context.args.expected_content,
|
|
710
|
+
expected_hash=context.args.expected_hash,
|
|
711
|
+
dry_run=context.args.dry_run,
|
|
712
|
+
actor=getattr(context.args, "actor", None),
|
|
713
|
+
task_id=getattr(context.args, "task_id", None),
|
|
714
|
+
)
|
|
715
|
+
return _emit_mutation(context, result)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _handle_delete_lines(context: _Context) -> int:
|
|
719
|
+
result = context.service.delete_lines(
|
|
720
|
+
context.args.path,
|
|
721
|
+
context.args.start_line,
|
|
722
|
+
context.args.end_line,
|
|
723
|
+
expected_content=context.args.expected_content,
|
|
724
|
+
expected_hash=context.args.expected_hash,
|
|
725
|
+
dry_run=context.args.dry_run,
|
|
726
|
+
actor=getattr(context.args, "actor", None),
|
|
727
|
+
task_id=getattr(context.args, "task_id", None),
|
|
728
|
+
)
|
|
729
|
+
return _emit_mutation(context, result)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _handle_apply_patch(context: _Context) -> int:
|
|
733
|
+
patch_file = context.args.patch_file
|
|
734
|
+
if patch_file is None:
|
|
735
|
+
if sys.stdin.isatty():
|
|
736
|
+
raise ValidationError("provide a patch file or patch content on standard input")
|
|
737
|
+
patch_text = sys.stdin.read()
|
|
738
|
+
else:
|
|
739
|
+
try:
|
|
740
|
+
patch_text = Path(patch_file).read_text(encoding="utf-8")
|
|
741
|
+
except OSError as exc:
|
|
742
|
+
raise ValidationError(
|
|
743
|
+
f"cannot read patch file: {exc}", details={"patch": str(patch_file)}
|
|
744
|
+
) from exc
|
|
745
|
+
except UnicodeDecodeError as exc:
|
|
746
|
+
raise ValidationError(
|
|
747
|
+
f"patch file is not UTF-8 text: {exc}", details={"patch": str(patch_file)}
|
|
748
|
+
) from exc
|
|
749
|
+
result = context.service.apply_patch(
|
|
750
|
+
patch_text,
|
|
751
|
+
allow_sensitive_write=context.args.allow_sensitive_write,
|
|
752
|
+
dry_run=context.args.dry_run,
|
|
753
|
+
actor=getattr(context.args, "actor", None),
|
|
754
|
+
task_id=getattr(context.args, "task_id", None),
|
|
755
|
+
)
|
|
756
|
+
return _emit_mutation(context, result)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _handle_dir_create(context: _Context) -> int:
|
|
760
|
+
result = context.service.create_directory(
|
|
761
|
+
context.args.path,
|
|
762
|
+
parents=context.args.parents,
|
|
763
|
+
dry_run=context.args.dry_run,
|
|
764
|
+
actor=getattr(context.args, "actor", None),
|
|
765
|
+
task_id=getattr(context.args, "task_id", None),
|
|
766
|
+
)
|
|
767
|
+
return _emit_mutation(context, result)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _handle_dir_move(context: _Context) -> int:
|
|
771
|
+
result = context.service.move_directory(
|
|
772
|
+
context.args.source,
|
|
773
|
+
context.args.destination,
|
|
774
|
+
dry_run=context.args.dry_run,
|
|
775
|
+
actor=getattr(context.args, "actor", None),
|
|
776
|
+
task_id=getattr(context.args, "task_id", None),
|
|
777
|
+
)
|
|
778
|
+
return _emit_mutation(context, result)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _handle_dir_delete(context: _Context) -> int:
|
|
782
|
+
result = context.service.delete_directory(
|
|
783
|
+
context.args.path,
|
|
784
|
+
recursive=context.args.recursive,
|
|
785
|
+
confirm_recursive=context.args.confirm_recursive,
|
|
786
|
+
dry_run=context.args.dry_run,
|
|
787
|
+
actor=getattr(context.args, "actor", None),
|
|
788
|
+
task_id=getattr(context.args, "task_id", None),
|
|
789
|
+
)
|
|
790
|
+
return _emit_mutation(context, result)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _handle_history_list(context: _Context) -> int:
|
|
794
|
+
operations = context.service.list_history(
|
|
795
|
+
path=getattr(context.args, "path", None),
|
|
796
|
+
actor=getattr(context.args, "actor", None),
|
|
797
|
+
task_id=getattr(context.args, "task_id", None),
|
|
798
|
+
limit=context.args.limit,
|
|
799
|
+
)
|
|
800
|
+
context.emit(
|
|
801
|
+
{
|
|
802
|
+
"ok": True,
|
|
803
|
+
"operations": output.jsonable(operations),
|
|
804
|
+
"count": len(operations),
|
|
805
|
+
},
|
|
806
|
+
output.history_list(operations),
|
|
807
|
+
)
|
|
808
|
+
return exit_codes.SUCCESS
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _handle_history_show(context: _Context) -> int:
|
|
812
|
+
record = context.service.get_operation(context.args.operation_id)
|
|
813
|
+
context.emit(
|
|
814
|
+
{"ok": True, "operation": output.jsonable(record)},
|
|
815
|
+
output.history_show(record),
|
|
816
|
+
)
|
|
817
|
+
return exit_codes.SUCCESS
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _handle_history_diff(context: _Context) -> int:
|
|
821
|
+
diff = context.service.diff_operation(context.args.operation_id)
|
|
822
|
+
context.emit(
|
|
823
|
+
{"ok": True, "operation_id": diff.operation_id, "files": output.jsonable(diff.items)},
|
|
824
|
+
output.history_diff(diff),
|
|
825
|
+
)
|
|
826
|
+
return exit_codes.SUCCESS
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _handle_history_undo(context: _Context) -> int:
|
|
830
|
+
result = context.service.undo(
|
|
831
|
+
context.args.operation_id,
|
|
832
|
+
actor=getattr(context.args, "actor", None),
|
|
833
|
+
task_id=getattr(context.args, "task_id", None),
|
|
834
|
+
)
|
|
835
|
+
return _emit_mutation(context, result)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _handle_history_undo_last(context: _Context) -> int:
|
|
839
|
+
result = context.service.undo_last(
|
|
840
|
+
actor=getattr(context.args, "actor", None),
|
|
841
|
+
task_id=getattr(context.args, "task_id", None),
|
|
842
|
+
)
|
|
843
|
+
return _emit_mutation(context, result)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
847
|
+
"""Parse arguments, run one command, return a stable exit code."""
|
|
848
|
+
|
|
849
|
+
parser = build_parser()
|
|
850
|
+
args = parser.parse_args(argv)
|
|
851
|
+
context = _Context(args)
|
|
852
|
+
try:
|
|
853
|
+
return int(args.handler(context))
|
|
854
|
+
except FilekeeperError as error:
|
|
855
|
+
output.print_error(error, as_json=context.as_json)
|
|
856
|
+
return exit_codes.exit_code_for(error)
|
|
857
|
+
except BrokenPipeError: # pragma: no cover - piping into head and friends
|
|
858
|
+
return exit_codes.SUCCESS
|
|
859
|
+
except OSError as error:
|
|
860
|
+
unrecognised = FilekeeperError(f"filesystem error: {error}")
|
|
861
|
+
output.print_error(unrecognised, as_json=context.as_json)
|
|
862
|
+
return exit_codes.GENERAL_ERROR
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
if __name__ == "__main__": # pragma: no cover
|
|
866
|
+
sys.exit(main())
|