agent-memory-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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agent_memory/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Cross-session memory for coding agents: plain files, git-native, no server."""
|
|
4
|
+
|
|
5
|
+
from .home import HomeError, HomeResolution, resolve_home
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"]
|
agent_memory/__main__.py
ADDED
agent_memory/archive.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Archive and restore supplementary memory files without clobbering or escaping.
|
|
4
|
+
|
|
5
|
+
A project's memory directory holds the active files the layout names plus
|
|
6
|
+
any number of supplementary files. ``archive`` moves one supplementary file
|
|
7
|
+
into ``memory/archive/`` under ``<UTC timestamp>_<basename>``; ``restore``
|
|
8
|
+
moves an archived file back to its original basename, into an active slot
|
|
9
|
+
that must be empty. The active files are never archived.
|
|
10
|
+
|
|
11
|
+
Three invariants replace check-then-rename:
|
|
12
|
+
|
|
13
|
+
* **No replace.** The move is ``os.link`` then ``os.unlink``. The link fails
|
|
14
|
+
if the destination exists at the instant it is made, so a file that
|
|
15
|
+
appears between any check and the move is never overwritten, and the
|
|
16
|
+
moved file keeps its bytes and metadata because it is the same inode. A
|
|
17
|
+
filesystem without hard links is refused rather than worked around.
|
|
18
|
+
* **Containment.** The project name and both basenames are validated
|
|
19
|
+
lexically. The home is resolved once; every directory below it
|
|
20
|
+
(``projects``, the project, ``memory``, ``memory/archive``) is opened one
|
|
21
|
+
path component at a time with ``O_NOFOLLOW``, so a symlink at any level is
|
|
22
|
+
refused, and the resulting directory handles address the file for every
|
|
23
|
+
check and for the move itself. A directory swapped for a symlink after it
|
|
24
|
+
was checked cannot redirect the link or the unlink: both run relative to
|
|
25
|
+
the handle, never by re-resolving a path. The file itself may not be a
|
|
26
|
+
symlink.
|
|
27
|
+
* **Protected identity.** An active file is protected by what it is, not by
|
|
28
|
+
how it is spelled: a source whose inode is one of the active files (a case
|
|
29
|
+
variant on a case-insensitive filesystem, a normalization variant, a hard
|
|
30
|
+
link) is refused like the exact name.
|
|
31
|
+
|
|
32
|
+
A dry run performs every check, including the ones on the archive path, and
|
|
33
|
+
reports the same paths; only the directory creation and the move are
|
|
34
|
+
skipped. POSIX only: Windows is refused, dry run included.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import datetime as dt
|
|
40
|
+
import errno
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import stat
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
46
|
+
|
|
47
|
+
from . import layout
|
|
48
|
+
|
|
49
|
+
ARCHIVE_DIR = "archive"
|
|
50
|
+
#: The active files of a project tier; these are never archived.
|
|
51
|
+
PROTECTED_FILES: Tuple[str, ...] = layout.PROJECT.files
|
|
52
|
+
_PLATFORM = os.name
|
|
53
|
+
|
|
54
|
+
#: errno values a filesystem without hard links (or one that forbids them) answers os.link with.
|
|
55
|
+
_NO_HARD_LINKS = frozenset(
|
|
56
|
+
code for code in (getattr(errno, name, None) for name in ("EPERM", "EXDEV", "ENOTSUP", "EOPNOTSUPP", "EACCES")) if code
|
|
57
|
+
)
|
|
58
|
+
#: errno values open(O_NOFOLLOW) answers with when the last component is a symlink (Linux/macOS: ELOOP; BSD: EMLINK).
|
|
59
|
+
_IS_SYMLINK = frozenset(code for code in (getattr(errno, name, None) for name in ("ELOOP", "EMLINK")) if code)
|
|
60
|
+
#: Open a directory by one component, never following a symlink at that component.
|
|
61
|
+
_DIR_FLAGS = (
|
|
62
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_SAFE_BASENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
66
|
+
_ARCHIVED_BASENAME_RE = re.compile(r"^(?P<timestamp>\d{8}T\d{6}Z)_(?P<basename>[A-Za-z0-9][A-Za-z0-9._-]{0,127})$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ArchiveError(Exception):
|
|
70
|
+
"""The operation was refused; nothing was moved."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- names -----------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_memory_basename(file_name: str) -> None:
|
|
77
|
+
if "/" in file_name or "\\" in file_name or not _SAFE_BASENAME_RE.fullmatch(file_name):
|
|
78
|
+
raise ArchiveError("memory file name must be a simple basename containing only [A-Za-z0-9._-]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_archive_name(memory_file: str, now: Optional[dt.datetime] = None) -> str:
|
|
82
|
+
validate_memory_basename(memory_file)
|
|
83
|
+
current = now or dt.datetime.now(dt.timezone.utc)
|
|
84
|
+
return f"{current.astimezone(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}_{memory_file}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def original_name_from_archive(archived_file: str) -> str:
|
|
88
|
+
if "/" in archived_file or "\\" in archived_file:
|
|
89
|
+
raise ArchiveError("archived file name must be a simple basename")
|
|
90
|
+
match = _ARCHIVED_BASENAME_RE.fullmatch(archived_file)
|
|
91
|
+
if match is None:
|
|
92
|
+
raise ArchiveError("archived file name must match <UTC timestamp>_<basename>")
|
|
93
|
+
return match.group("basename")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- the verbs -------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def archive(
|
|
100
|
+
home: Path,
|
|
101
|
+
project: str,
|
|
102
|
+
memory_file: str,
|
|
103
|
+
*,
|
|
104
|
+
dry_run: bool = False,
|
|
105
|
+
now: Optional[dt.datetime] = None,
|
|
106
|
+
) -> Dict[str, Any]:
|
|
107
|
+
"""Move ``memory/<memory_file>`` to ``memory/archive/<UTC>_<memory_file>``."""
|
|
108
|
+
_require_posix()
|
|
109
|
+
validate_memory_basename(memory_file)
|
|
110
|
+
if memory_file in PROTECTED_FILES:
|
|
111
|
+
raise ArchiveError(f"cannot archive standard active memory file: {memory_file}")
|
|
112
|
+
archived_file = build_archive_name(memory_file, now=now)
|
|
113
|
+
memory_dir, memory_fd = _open_memory_dir(home, project)
|
|
114
|
+
archive_dir = memory_dir / ARCHIVE_DIR
|
|
115
|
+
source = memory_dir / memory_file
|
|
116
|
+
destination = archive_dir / archived_file
|
|
117
|
+
try:
|
|
118
|
+
# The layout is validated before the file is looked up, on the path both runs share:
|
|
119
|
+
# a symlink or a non-directory at memory/archive is refused here; an absent one is None.
|
|
120
|
+
archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False)
|
|
121
|
+
try:
|
|
122
|
+
source_stat = _stat_regular_file(memory_file, memory_fd, source, "memory file")
|
|
123
|
+
_refuse_active_identity(source_stat, memory_fd, memory_dir, memory_file)
|
|
124
|
+
if archive_fd is not None:
|
|
125
|
+
_require_absent(archived_file, archive_fd, destination, "archive destination")
|
|
126
|
+
if not dry_run:
|
|
127
|
+
if archive_fd is None:
|
|
128
|
+
archive_fd = _open_archive_dir(memory_fd, archive_dir, create=True)
|
|
129
|
+
if archive_fd is None: # unreachable: create=True never returns None
|
|
130
|
+
raise ArchiveError(f"archive directory not found: {archive_dir}")
|
|
131
|
+
_move_no_replace(memory_file, memory_fd, archived_file, archive_fd, source_stat, source, destination)
|
|
132
|
+
finally:
|
|
133
|
+
if archive_fd is not None:
|
|
134
|
+
os.close(archive_fd)
|
|
135
|
+
finally:
|
|
136
|
+
os.close(memory_fd)
|
|
137
|
+
return {
|
|
138
|
+
"project": project,
|
|
139
|
+
"action": "archive",
|
|
140
|
+
"memory_file": memory_file,
|
|
141
|
+
"archived_file": archived_file,
|
|
142
|
+
"source": str(source),
|
|
143
|
+
"destination": str(destination),
|
|
144
|
+
"dry_run": dry_run,
|
|
145
|
+
"status": "dry-run" if dry_run else "archived",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def restore(home: Path, project: str, archived_file: str, *, dry_run: bool = False) -> Dict[str, Any]:
|
|
150
|
+
"""Move ``memory/archive/<archived_file>`` back to ``memory/<basename>``, which must not exist."""
|
|
151
|
+
_require_posix()
|
|
152
|
+
restored_file = original_name_from_archive(archived_file)
|
|
153
|
+
memory_dir, memory_fd = _open_memory_dir(home, project)
|
|
154
|
+
archive_dir = memory_dir / ARCHIVE_DIR
|
|
155
|
+
source = archive_dir / archived_file
|
|
156
|
+
destination = memory_dir / restored_file
|
|
157
|
+
try:
|
|
158
|
+
archive_fd = _open_archive_dir(memory_fd, archive_dir, create=False)
|
|
159
|
+
if archive_fd is None:
|
|
160
|
+
raise ArchiveError(f"memory archive directory not found: {archive_dir}")
|
|
161
|
+
try:
|
|
162
|
+
source_stat = _stat_regular_file(archived_file, archive_fd, source, "archived memory file")
|
|
163
|
+
_require_absent(restored_file, memory_fd, destination, "active memory destination")
|
|
164
|
+
if not dry_run:
|
|
165
|
+
_move_no_replace(archived_file, archive_fd, restored_file, memory_fd, source_stat, source, destination)
|
|
166
|
+
finally:
|
|
167
|
+
os.close(archive_fd)
|
|
168
|
+
finally:
|
|
169
|
+
os.close(memory_fd)
|
|
170
|
+
return {
|
|
171
|
+
"project": project,
|
|
172
|
+
"action": "restore",
|
|
173
|
+
"archived_file": archived_file,
|
|
174
|
+
"restored_file": restored_file,
|
|
175
|
+
"source": str(source),
|
|
176
|
+
"destination": str(destination),
|
|
177
|
+
"dry_run": dry_run,
|
|
178
|
+
"status": "dry-run" if dry_run else "restored",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# --- containment: directory handles -----------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _require_posix() -> None:
|
|
186
|
+
if _PLATFORM != "posix":
|
|
187
|
+
raise ArchiveError("archive and restore are supported on POSIX systems only")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _open_memory_dir(home: Path, project: str) -> Tuple[Path, int]:
|
|
191
|
+
"""The project's memory directory as (path, handle), every directory below the home opened without following symlinks."""
|
|
192
|
+
try:
|
|
193
|
+
layout.validate_project_name(project)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
raise ArchiveError(str(exc)) from None
|
|
196
|
+
root = Path(home).expanduser().resolve()
|
|
197
|
+
memory_dir = layout.project_memory_dir(root, project)
|
|
198
|
+
project_dir = memory_dir.parent
|
|
199
|
+
fd = _open_dir(str(root), None, root, "home", lambda: f"home not found: {root}")
|
|
200
|
+
current = root
|
|
201
|
+
for part in memory_dir.relative_to(root).parts:
|
|
202
|
+
current = current / part
|
|
203
|
+
if current == memory_dir:
|
|
204
|
+
missing = f"memory directory not found: {memory_dir}"
|
|
205
|
+
else:
|
|
206
|
+
missing = f"project '{project}' not found at {project_dir}"
|
|
207
|
+
try:
|
|
208
|
+
child = _open_dir(part, fd, current, "directory", lambda: missing)
|
|
209
|
+
finally:
|
|
210
|
+
os.close(fd)
|
|
211
|
+
fd = child
|
|
212
|
+
return memory_dir, fd
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _open_archive_dir(memory_fd: int, archive_dir: Path, *, create: bool) -> Optional[int]:
|
|
216
|
+
"""A handle on ``memory/archive``; ``None`` when it is absent and not to be created.
|
|
217
|
+
|
|
218
|
+
A symlink or a non-directory at that name is refused here, on the path
|
|
219
|
+
both dry run and real run share, so a dry run never reports a move the
|
|
220
|
+
real run could not perform.
|
|
221
|
+
"""
|
|
222
|
+
created = False
|
|
223
|
+
while True:
|
|
224
|
+
try:
|
|
225
|
+
return os.open(ARCHIVE_DIR, _DIR_FLAGS, dir_fd=memory_fd)
|
|
226
|
+
except OSError as exc:
|
|
227
|
+
if exc.errno == errno.ENOENT and not create:
|
|
228
|
+
return None
|
|
229
|
+
if exc.errno == errno.ENOENT and not created:
|
|
230
|
+
try:
|
|
231
|
+
os.mkdir(ARCHIVE_DIR, dir_fd=memory_fd)
|
|
232
|
+
except FileExistsError:
|
|
233
|
+
pass
|
|
234
|
+
except OSError as mkdir_exc:
|
|
235
|
+
raise ArchiveError(
|
|
236
|
+
f"cannot create the archive directory {archive_dir}: {mkdir_exc.strerror}"
|
|
237
|
+
) from None
|
|
238
|
+
created = True
|
|
239
|
+
continue
|
|
240
|
+
raise _open_error(
|
|
241
|
+
exc, ARCHIVE_DIR, memory_fd, archive_dir, "archive directory", lambda: f"archive directory not found: {archive_dir}"
|
|
242
|
+
) from None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _open_dir(name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str]) -> int:
|
|
246
|
+
try:
|
|
247
|
+
return os.open(name, _DIR_FLAGS, dir_fd=dir_fd)
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
raise _open_error(exc, name, dir_fd, path, what, missing) from None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _open_error(
|
|
253
|
+
exc: OSError, name: str, dir_fd: Optional[int], path: Path, what: str, missing: Callable[[], str]
|
|
254
|
+
) -> ArchiveError:
|
|
255
|
+
# Linux answers O_NOFOLLOW on a symlink with ELOOP; macOS answers O_DIRECTORY|O_NOFOLLOW with ENOTDIR.
|
|
256
|
+
# Either way the open refused it; the lstat only decides which refusal to name.
|
|
257
|
+
if exc.errno in _IS_SYMLINK or (exc.errno == errno.ENOTDIR and _is_symlink(name, dir_fd)):
|
|
258
|
+
return ArchiveError(f"refusing to operate through a symlink: {what} {path} is a symlink")
|
|
259
|
+
if exc.errno == errno.ENOTDIR:
|
|
260
|
+
return ArchiveError(f"{what} {path} is not a directory")
|
|
261
|
+
if exc.errno == errno.ENOENT:
|
|
262
|
+
return ArchiveError(missing())
|
|
263
|
+
return ArchiveError(f"cannot open {what} {path}: {exc.strerror}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _is_symlink(name: str, dir_fd: Optional[int]) -> bool:
|
|
267
|
+
try:
|
|
268
|
+
return stat.S_ISLNK(os.stat(name, dir_fd=dir_fd, follow_symlinks=False).st_mode)
|
|
269
|
+
except OSError:
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _stat_regular_file(name: str, dir_fd: int, path: Path, what: str) -> os.stat_result:
|
|
274
|
+
try:
|
|
275
|
+
st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
276
|
+
except FileNotFoundError:
|
|
277
|
+
raise ArchiveError(f"{what} not found: {path}") from None
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None
|
|
280
|
+
if stat.S_ISLNK(st.st_mode):
|
|
281
|
+
raise ArchiveError(f"refusing to move a symlink: {what} {path}")
|
|
282
|
+
if not stat.S_ISREG(st.st_mode):
|
|
283
|
+
raise ArchiveError(f"{what} is not a regular file: {path}")
|
|
284
|
+
return st
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _require_absent(name: str, dir_fd: int, path: Path, what: str) -> None:
|
|
288
|
+
try:
|
|
289
|
+
os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
return
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
raise ArchiveError(f"cannot stat {what} {path}: {exc.strerror}") from None
|
|
294
|
+
raise ArchiveError(f"{what} already exists: {path}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _refuse_active_identity(source_stat: os.stat_result, memory_fd: int, memory_dir: Path, memory_file: str) -> None:
|
|
298
|
+
"""Refuse a source that *is* an active file, whatever name addresses it on this filesystem."""
|
|
299
|
+
for protected in PROTECTED_FILES:
|
|
300
|
+
try:
|
|
301
|
+
st = os.stat(protected, dir_fd=memory_fd, follow_symlinks=False)
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
continue
|
|
304
|
+
except OSError as exc:
|
|
305
|
+
raise ArchiveError(f"cannot stat memory file {memory_dir / protected}: {exc.strerror}") from None
|
|
306
|
+
if (st.st_dev, st.st_ino) == (source_stat.st_dev, source_stat.st_ino):
|
|
307
|
+
raise ArchiveError(
|
|
308
|
+
f"cannot archive standard active memory file: {memory_file} is {protected} on this filesystem"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# --- the move --------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _move_no_replace(
|
|
316
|
+
src_name: str,
|
|
317
|
+
src_fd: int,
|
|
318
|
+
dst_name: str,
|
|
319
|
+
dst_fd: int,
|
|
320
|
+
expected: os.stat_result,
|
|
321
|
+
source: Path,
|
|
322
|
+
destination: Path,
|
|
323
|
+
) -> None:
|
|
324
|
+
"""Link ``src_name`` (in ``src_fd``) as ``dst_name`` (in ``dst_fd``) without replacing anything, then unlink the source.
|
|
325
|
+
|
|
326
|
+
Both syscalls run relative to the directory handles, so nothing re-resolves a
|
|
327
|
+
path after validation. The new link must be a regular file with the identity
|
|
328
|
+
that was checked; a symlink or a different inode planted at the source name
|
|
329
|
+
meanwhile is refused and the link (ours by construction) removed. Best effort
|
|
330
|
+
for a regular file: some filesystems reuse a freed inode number at once. The
|
|
331
|
+
name lies inside the validated directory either way.
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
os.link(src_name, dst_name, src_dir_fd=src_fd, dst_dir_fd=dst_fd, follow_symlinks=False)
|
|
335
|
+
except FileExistsError:
|
|
336
|
+
raise ArchiveError(f"destination already exists: {destination}") from None
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
if exc.errno in _NO_HARD_LINKS:
|
|
339
|
+
raise ArchiveError(
|
|
340
|
+
f"cannot link {source} to {destination}: {exc.strerror}; "
|
|
341
|
+
"archive and restore need a filesystem with hard links"
|
|
342
|
+
) from None
|
|
343
|
+
raise ArchiveError(f"cannot link {source} to {destination}: {exc.strerror}") from None
|
|
344
|
+
try:
|
|
345
|
+
linked = os.stat(dst_name, dir_fd=dst_fd, follow_symlinks=False)
|
|
346
|
+
except OSError as exc:
|
|
347
|
+
raise ArchiveError(f"cannot stat the new link {destination}: {exc.strerror}") from None
|
|
348
|
+
if (linked.st_dev, linked.st_ino) != (expected.st_dev, expected.st_ino) or not stat.S_ISREG(linked.st_mode):
|
|
349
|
+
try:
|
|
350
|
+
os.unlink(dst_name, dir_fd=dst_fd)
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
raise ArchiveError(f"refusing to complete the move: {source} changed after it was checked")
|
|
354
|
+
try:
|
|
355
|
+
os.unlink(src_name, dir_fd=src_fd)
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
raise ArchiveError(
|
|
358
|
+
f"archived copy created at {destination} but the source could not be removed: {source}: {exc.strerror}"
|
|
359
|
+
) from None
|