kd-cli 2.4.0__tar.gz → 2.4.1__tar.gz
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.
- {kd_cli-2.4.0/kd_cli.egg-info → kd_cli-2.4.1}/PKG-INFO +1 -1
- kd_cli-2.4.1/kd/locking.py +97 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/primer.py +74 -64
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/CHANGELOG.md +5 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/operating-guide.md +2 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/SOURCES.txt +1 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/pyproject.toml +1 -1
- {kd_cli-2.4.0 → kd_cli-2.4.1}/LICENSE +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/MANIFEST.in +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/README.md +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/articles.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/http.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/hub.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/saved_queries.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/tags.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/api/youtrack.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/article_marker.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/auth.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/confirm.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/input.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/main.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/system/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/output.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/cli/tree.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/client.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config_read.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/config_schema.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/connection.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/debug.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/exceptions.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/history.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/article.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/assist.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/attachment.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/comment.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/common.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/issue.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/project.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/saved_query.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/search.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/tag.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/tag_entities.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/user.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/models/work_item.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/operating_guide.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/query_parser.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/release_notes.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/primers/__init__.py +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd/resources/primers/hitl.yaml +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.4.0 → kd_cli-2.4.1}/setup.cfg +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Advisory file locking for read-modify-write transactions on kd-managed files.
|
|
2
|
+
|
|
3
|
+
Provides :func:`file_transaction` — a context manager that acquires an
|
|
4
|
+
exclusive advisory lock on a sidecar lockfile (``<path>.lock``) for the
|
|
5
|
+
duration of a load → mutate → save cycle. Concurrent kd processes that
|
|
6
|
+
enter the same transaction serialize rather than clobber each other.
|
|
7
|
+
|
|
8
|
+
Design notes:
|
|
9
|
+
|
|
10
|
+
* **Sidecar lockfile, not the target file.** Atomic writes elsewhere in
|
|
11
|
+
kd use ``tempfile.mkstemp`` + ``os.replace``, which changes the target
|
|
12
|
+
file's inode on every write. Holding ``flock`` on the target inode
|
|
13
|
+
would not protect a second writer that opens the freshly-replaced file.
|
|
14
|
+
Locking a separate sidecar file that nobody rewrites keeps the lock
|
|
15
|
+
stable across atomic replaces of the data file.
|
|
16
|
+
|
|
17
|
+
* **No unlink on release.** Removing the lockfile after releasing the
|
|
18
|
+
lock is a classic race: process B can open the doomed inode while
|
|
19
|
+
process C creates a fresh one, and both end up holding "exclusive"
|
|
20
|
+
locks on different inodes. Empty sidecar files are cheap; leave them.
|
|
21
|
+
|
|
22
|
+
* **POSIX only.** ``fcntl`` is imported lazily inside the context
|
|
23
|
+
manager so that unrelated kd commands remain importable on Windows.
|
|
24
|
+
On non-POSIX platforms, :func:`file_transaction` raises ``ConfigError``
|
|
25
|
+
with a suggestion to run the command sequentially.
|
|
26
|
+
|
|
27
|
+
* **NFS caveat.** ``flock`` on NFS has been unreliable historically.
|
|
28
|
+
Modern Linux kernels implement it correctly, but operators using a
|
|
29
|
+
networked ``$HOME`` should be aware that concurrency guarantees weaken.
|
|
30
|
+
Local ``.kd/`` directories inside a repo are unaffected.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from collections.abc import Generator
|
|
39
|
+
from contextlib import contextmanager
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from kd.exceptions import ConfigError
|
|
43
|
+
|
|
44
|
+
LOCK_SUFFIX = ".lock"
|
|
45
|
+
_POLL_INTERVAL_SECONDS = 0.05
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def file_transaction(path: Path, *, timeout: float = 30.0) -> Generator[None, None, None]:
|
|
50
|
+
"""Hold an exclusive advisory lock on ``<path>.lock`` for the ``with`` body.
|
|
51
|
+
|
|
52
|
+
The sidecar lockfile is created if missing (``O_RDWR | O_CREAT``,
|
|
53
|
+
never truncated). Acquisition polls non-blocking ``flock`` until
|
|
54
|
+
the lock is granted or *timeout* seconds elapse. On timeout, raises
|
|
55
|
+
:class:`ConfigError` naming the contested file. The lockfile is
|
|
56
|
+
intentionally left on disk after release.
|
|
57
|
+
|
|
58
|
+
Raises :class:`ConfigError` on non-POSIX platforms.
|
|
59
|
+
"""
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
raise ConfigError(
|
|
62
|
+
"File locking is not available on this platform (POSIX only).",
|
|
63
|
+
suggestion="Run the kd command sequentially; concurrent mutations are not supported on Windows.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
import fcntl # noqa: PLC0415 — lazy import keeps kd importable on Windows
|
|
67
|
+
|
|
68
|
+
lock_path = path.with_name(path.name + LOCK_SUFFIX)
|
|
69
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
|
72
|
+
try:
|
|
73
|
+
deadline = time.monotonic() + timeout
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
77
|
+
break
|
|
78
|
+
except BlockingIOError:
|
|
79
|
+
if time.monotonic() >= deadline:
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
f"Another kd process is writing to {path}; retry in a moment.",
|
|
82
|
+
suggestion=f"If no other kd process is running, remove the stale lockfile: {lock_path}",
|
|
83
|
+
) from None
|
|
84
|
+
time.sleep(_POLL_INTERVAL_SECONDS)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
yield
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
finally:
|
|
94
|
+
try:
|
|
95
|
+
os.close(fd)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
@@ -11,6 +11,8 @@ knowledge). The file is created lazily on first write operation.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import importlib.resources
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from contextlib import contextmanager
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any
|
|
16
18
|
|
|
@@ -18,6 +20,7 @@ import yaml
|
|
|
18
20
|
from pydantic import BaseModel
|
|
19
21
|
|
|
20
22
|
from kd.exceptions import ConfigError, ValidationError
|
|
23
|
+
from kd.locking import file_transaction
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
# ---------------------------------------------------------------------------
|
|
@@ -125,10 +128,15 @@ def load_primer(path: Path | None = None) -> PrimerData:
|
|
|
125
128
|
|
|
126
129
|
|
|
127
130
|
def save_primer(data: PrimerData, path: Path | None = None) -> None:
|
|
128
|
-
"""Write primer to YAML.
|
|
131
|
+
"""Write primer to YAML — low-level primitive.
|
|
129
132
|
|
|
130
133
|
Snapshots the current file before writing (copy-on-write) and uses
|
|
131
|
-
atomic writes to prevent partial-write corruption.
|
|
134
|
+
atomic writes to prevent partial-write corruption. Does **not**
|
|
135
|
+
serialize concurrent writers on its own; callers that do
|
|
136
|
+
``load_primer → mutate → save_primer`` across processes risk lost
|
|
137
|
+
updates. Prefer the mutator functions below (:func:`add_note`,
|
|
138
|
+
:func:`update_note`, etc.), which wrap load/save in
|
|
139
|
+
:func:`primer_transaction` to guarantee per-file serialization.
|
|
132
140
|
"""
|
|
133
141
|
from kd.history import atomic_write_text, snapshot_before_write
|
|
134
142
|
|
|
@@ -172,46 +180,56 @@ def _tag_dump(tag: dict[str, Any]) -> dict[str, Any]:
|
|
|
172
180
|
# ---------------------------------------------------------------------------
|
|
173
181
|
|
|
174
182
|
|
|
183
|
+
@contextmanager
|
|
184
|
+
def _primer_transaction(path: Path) -> Generator[PrimerData, None, None]:
|
|
185
|
+
"""Serialize a primer read-modify-write cycle under an advisory lock.
|
|
186
|
+
|
|
187
|
+
Holds an exclusive advisory lock on ``<path>.lock`` for the full
|
|
188
|
+
cycle: loads the primer, yields a ``PrimerData`` for the caller to
|
|
189
|
+
mutate in place, and saves on clean exit. If the caller raises,
|
|
190
|
+
the save is skipped (the file is not modified).
|
|
191
|
+
"""
|
|
192
|
+
with file_transaction(path):
|
|
193
|
+
data = load_primer(path)
|
|
194
|
+
yield data
|
|
195
|
+
save_primer(data, path)
|
|
196
|
+
|
|
197
|
+
|
|
175
198
|
def add_note(text: str, path: Path | None = None) -> int:
|
|
176
199
|
"""Append a note. Returns the 1-based index."""
|
|
177
200
|
primer_file = path or _primer_path()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return len(data.notes)
|
|
201
|
+
with _primer_transaction(primer_file) as data:
|
|
202
|
+
data.notes.append(text)
|
|
203
|
+
return len(data.notes)
|
|
182
204
|
|
|
183
205
|
|
|
184
206
|
def remove_note(index: int, path: Path | None = None) -> str:
|
|
185
207
|
"""Remove a note by 1-based index. Returns the removed text."""
|
|
186
208
|
primer_file = path or _primer_path()
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
save_primer(data, primer_file)
|
|
192
|
-
return removed
|
|
209
|
+
with _primer_transaction(primer_file) as data:
|
|
210
|
+
if index < 1 or index > len(data.notes):
|
|
211
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
212
|
+
return data.notes.pop(index - 1)
|
|
193
213
|
|
|
194
214
|
|
|
195
215
|
def update_note(index: int, text: str, path: Path | None = None) -> str:
|
|
196
216
|
"""Replace a note by 1-based index. Returns the old text."""
|
|
197
217
|
primer_file = path or _primer_path()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return old
|
|
218
|
+
with _primer_transaction(primer_file) as data:
|
|
219
|
+
if index < 1 or index > len(data.notes):
|
|
220
|
+
raise ValidationError(f"Note index {index} out of range (1–{len(data.notes)})")
|
|
221
|
+
old = data.notes[index - 1]
|
|
222
|
+
data.notes[index - 1] = text
|
|
223
|
+
return old
|
|
205
224
|
|
|
206
225
|
|
|
207
226
|
def clear_notes(path: Path | None = None) -> int:
|
|
208
227
|
"""Remove all notes. Returns the count removed."""
|
|
209
228
|
primer_file = path or _primer_path()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return count
|
|
229
|
+
with _primer_transaction(primer_file) as data:
|
|
230
|
+
count = len(data.notes)
|
|
231
|
+
data.notes.clear()
|
|
232
|
+
return count
|
|
215
233
|
|
|
216
234
|
|
|
217
235
|
def add_article(
|
|
@@ -219,71 +237,63 @@ def add_article(
|
|
|
219
237
|
) -> PrimerArticle:
|
|
220
238
|
"""Pin an article. Raises if already pinned."""
|
|
221
239
|
primer_file = path or _primer_path()
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return article
|
|
240
|
+
with _primer_transaction(primer_file) as data:
|
|
241
|
+
for existing in data.articles:
|
|
242
|
+
if existing.id == article_id:
|
|
243
|
+
raise ValidationError(f"Article '{article_id}' is already pinned")
|
|
244
|
+
article = PrimerArticle(id=article_id, summary=summary)
|
|
245
|
+
data.articles.append(article)
|
|
246
|
+
return article
|
|
230
247
|
|
|
231
248
|
|
|
232
249
|
def remove_article(article_id: str, path: Path | None = None) -> PrimerArticle:
|
|
233
250
|
"""Unpin an article by ID. Returns the removed entry."""
|
|
234
251
|
primer_file = path or _primer_path()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return removed
|
|
241
|
-
raise ValidationError(f"Article '{article_id}' is not pinned")
|
|
252
|
+
with _primer_transaction(primer_file) as data:
|
|
253
|
+
for i, article in enumerate(data.articles):
|
|
254
|
+
if article.id == article_id:
|
|
255
|
+
return data.articles.pop(i)
|
|
256
|
+
raise ValidationError(f"Article '{article_id}' is not pinned")
|
|
242
257
|
|
|
243
258
|
|
|
244
259
|
def clear_articles(path: Path | None = None) -> int:
|
|
245
260
|
"""Remove all pinned articles. Returns the count removed."""
|
|
246
261
|
primer_file = path or _primer_path()
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return count
|
|
262
|
+
with _primer_transaction(primer_file) as data:
|
|
263
|
+
count = len(data.articles)
|
|
264
|
+
data.articles.clear()
|
|
265
|
+
return count
|
|
252
266
|
|
|
253
267
|
|
|
254
268
|
def add_tag(name: str, summary: str | None = None, path: Path | None = None) -> PrimerTag:
|
|
255
269
|
"""Pin a tag. Raises if already pinned."""
|
|
256
270
|
primer_file = path or _primer_path()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return tag
|
|
271
|
+
with _primer_transaction(primer_file) as data:
|
|
272
|
+
for existing in data.tags:
|
|
273
|
+
if existing.name == name:
|
|
274
|
+
raise ValidationError(f"Tag '{name}' is already pinned")
|
|
275
|
+
tag = PrimerTag(name=name, summary=summary)
|
|
276
|
+
data.tags.append(tag)
|
|
277
|
+
return tag
|
|
265
278
|
|
|
266
279
|
|
|
267
280
|
def remove_tag(name: str, path: Path | None = None) -> PrimerTag:
|
|
268
281
|
"""Unpin a tag by name. Returns the removed entry."""
|
|
269
282
|
primer_file = path or _primer_path()
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return removed
|
|
276
|
-
raise ValidationError(f"Tag '{name}' is not pinned")
|
|
283
|
+
with _primer_transaction(primer_file) as data:
|
|
284
|
+
for i, tag in enumerate(data.tags):
|
|
285
|
+
if tag.name == name:
|
|
286
|
+
return data.tags.pop(i)
|
|
287
|
+
raise ValidationError(f"Tag '{name}' is not pinned")
|
|
277
288
|
|
|
278
289
|
|
|
279
290
|
def clear_tags(path: Path | None = None) -> int:
|
|
280
291
|
"""Remove all pinned tags. Returns the count removed."""
|
|
281
292
|
primer_file = path or _primer_path()
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return count
|
|
293
|
+
with _primer_transaction(primer_file) as data:
|
|
294
|
+
count = len(data.tags)
|
|
295
|
+
data.tags.clear()
|
|
296
|
+
return count
|
|
287
297
|
|
|
288
298
|
|
|
289
299
|
# ---------------------------------------------------------------------------
|
|
@@ -4,6 +4,11 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.4.1] — 2026-04-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Concurrent `kd /primer/note`, `/primer/tag`, and `/primer/article` commands against the same `.kd/primer.yaml` no longer silently lose writes. Before: running three `kd /primer/note update` calls in parallel (common in agent-driven sessions) all reported `ok: true` but only one update survived. Now: each command runs inside an advisory file lock on a sidecar `.kd/primer.yaml.lock`, so concurrent kd processes queue rather than clobber each other and every successful call is reflected in the final file. Concurrent acquirers wait up to 30 s before erroring with `Another kd process is writing to …; retry in a moment`; the error includes the path of the sidecar lockfile for stale-holder recovery. POSIX only; first lock attempt on Windows raises a clear error pointing at sequential invocation (KDCLI-98).
|
|
11
|
+
|
|
7
12
|
## [2.4.0] — 2026-04-19
|
|
8
13
|
|
|
9
14
|
### Added
|
|
@@ -146,6 +146,8 @@ When both pinned tags and pinned articles exist, read them in order: notes tell
|
|
|
146
146
|
|
|
147
147
|
Keep the primer local and curated. If a note becomes generally true for kd, move it into docs or this guide. If it is workspace-specific, keep it in `.kd/primer.yaml`.
|
|
148
148
|
|
|
149
|
+
Primer mutations are serialized per file through an advisory lock on `.kd/primer.yaml.lock`; concurrent kd processes writing to the same primer queue rather than conflict, so agent-launched parallel `/primer/note`, `/primer/tag`, and `/primer/article` commands all converge without lost updates.
|
|
150
|
+
|
|
149
151
|
## Articles And Push
|
|
150
152
|
|
|
151
153
|
Use `/article show` for metadata plus rendered content. Use `/article pull` when you need raw article content on stdout; `pull` is format-agnostic and ignores `--output`.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|