obris-cli 0.6.2__tar.gz → 0.7.0__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.
- {obris_cli-0.6.2 → obris_cli-0.7.0}/PKG-INFO +1 -1
- {obris_cli-0.6.2 → obris_cli-0.7.0}/pyproject.toml +1 -1
- obris_cli-0.7.0/src/obris/commands/sync_config.py +290 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/run.py +7 -1
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/exclusions.py +35 -29
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/runner.py +20 -5
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/state.py +43 -21
- {obris_cli-0.6.2 → obris_cli-0.7.0}/uv.lock +1 -1
- obris_cli-0.6.2/src/obris/commands/sync_config.py +0 -178
- {obris_cli-0.6.2 → obris_cli-0.7.0}/.claude/settings.local.json +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/.gitignore +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/README.md +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/ruff.toml +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/client.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/knowledge.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/api/topics.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/auth/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/auth/session.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/cli.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/auth.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/env.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/save.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/sync.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/sync_conflicts.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/config.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/output.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/routes.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/commands.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/constants.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/filters.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/manifest.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/subtree.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/engine/tracked.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/io.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/mapping.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/models.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/resolver.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/sync/scanner.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/notify.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.0}/src/obris/utils/upload.py +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Per-checkout sync configuration subcommands.
|
|
2
|
+
|
|
3
|
+
Hosts ``exclude`` / ``include`` / ``untrack`` — commands that modify
|
|
4
|
+
state-file metadata for a synced directory. Kept out of
|
|
5
|
+
``commands.sync`` so the sync group definition + invocation logic
|
|
6
|
+
stays under the 300-line cap and these subcommands stay together
|
|
7
|
+
because they share the ``_states_for_current_dir`` resolver.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import pathspec
|
|
16
|
+
from pathspec.patterns.gitwildmatch import GitWildMatchPattern
|
|
17
|
+
|
|
18
|
+
from obris.sync.exclusions import ExclusionMatcher
|
|
19
|
+
from obris.sync.state import SyncState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _states_for_current_dir(path):
|
|
23
|
+
"""Return ``(sync_dir, states)`` for ``path``, exiting cleanly if unsynced."""
|
|
24
|
+
sync_dir = Path(path).resolve()
|
|
25
|
+
states = SyncState.find_all_for_path(sync_dir)
|
|
26
|
+
if not states:
|
|
27
|
+
raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
|
|
28
|
+
return sync_dir, states
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register(sync):
|
|
32
|
+
"""Attach the config subcommands to the given Click group."""
|
|
33
|
+
|
|
34
|
+
@sync.command("exclude")
|
|
35
|
+
@click.argument("patterns", nargs=-1)
|
|
36
|
+
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
37
|
+
@click.option("--list", "list_only", is_flag=True, help="Print current settings")
|
|
38
|
+
def sync_exclude(patterns, path, list_only):
|
|
39
|
+
"""Stop syncing files that match a pattern.
|
|
40
|
+
|
|
41
|
+
\b
|
|
42
|
+
obris sync exclude scratch/ # an entire folder
|
|
43
|
+
obris sync exclude notes.draft.md # a specific file
|
|
44
|
+
obris sync exclude '*.draft.md' # all drafts
|
|
45
|
+
obris sync exclude --list # show current settings
|
|
46
|
+
|
|
47
|
+
Wins over any prior ``obris sync include`` for the same
|
|
48
|
+
pattern, so the user-facing rule stays simple: "the last
|
|
49
|
+
thing you said is what happens."
|
|
50
|
+
"""
|
|
51
|
+
sync_dir, states = _states_for_current_dir(path)
|
|
52
|
+
|
|
53
|
+
if list_only:
|
|
54
|
+
if patterns:
|
|
55
|
+
raise click.UsageError("Cannot combine --list with patterns.")
|
|
56
|
+
_list_settings(states)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
if not patterns:
|
|
60
|
+
raise click.UsageError("Provide one or more patterns, or use --list.")
|
|
61
|
+
|
|
62
|
+
results = _apply_pattern_action(states, patterns, action="exclude", sync_dir=sync_dir)
|
|
63
|
+
_emit_outcomes(sync_dir, results, action="exclude")
|
|
64
|
+
|
|
65
|
+
@sync.command("include")
|
|
66
|
+
@click.argument("patterns", nargs=-1, required=True)
|
|
67
|
+
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
68
|
+
def sync_include(patterns, path):
|
|
69
|
+
"""Force a file or pattern to sync, overriding any exclusion.
|
|
70
|
+
|
|
71
|
+
Use this whenever ``obris sync`` says a file is being skipped
|
|
72
|
+
and you actually want it. Works whether the file was being
|
|
73
|
+
excluded by a built-in default or by a prior
|
|
74
|
+
``obris sync exclude``.
|
|
75
|
+
|
|
76
|
+
\b
|
|
77
|
+
obris sync include .env.example # sync this even though
|
|
78
|
+
# .env.* is excluded by default
|
|
79
|
+
obris sync include scratch/draft.md # sync one file inside an
|
|
80
|
+
# excluded folder
|
|
81
|
+
obris sync include '*.draft.md' # un-exclude a pattern you
|
|
82
|
+
# previously excluded
|
|
83
|
+
|
|
84
|
+
Wins over any prior ``obris sync exclude`` for the same pattern
|
|
85
|
+
— last call wins.
|
|
86
|
+
"""
|
|
87
|
+
sync_dir, states = _states_for_current_dir(path)
|
|
88
|
+
results = _apply_pattern_action(states, patterns, action="include", sync_dir=sync_dir)
|
|
89
|
+
_emit_outcomes(sync_dir, results, action="include")
|
|
90
|
+
|
|
91
|
+
@sync.command("untrack")
|
|
92
|
+
@click.argument("targets", nargs=-1, required=True)
|
|
93
|
+
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
94
|
+
def sync_untrack(targets, path):
|
|
95
|
+
"""Stop syncing items without deleting either side.
|
|
96
|
+
|
|
97
|
+
Each TARGET is a knowledge ID or a tracked filename (basename).
|
|
98
|
+
Removes the sync link; both the local file and the remote item stay
|
|
99
|
+
in place. Subsequent 'obris sync' calls will not re-pull these items.
|
|
100
|
+
|
|
101
|
+
\b
|
|
102
|
+
obris sync untrack abc123
|
|
103
|
+
obris sync untrack notes.md draft.md
|
|
104
|
+
|
|
105
|
+
Re-link later with 'obris sync link <file> -i <id>' or 'obris sync add'.
|
|
106
|
+
Permanently delete the remote with 'obris knowledge delete <id>'.
|
|
107
|
+
"""
|
|
108
|
+
sync_dir, states = _states_for_current_dir(path)
|
|
109
|
+
|
|
110
|
+
untracked = []
|
|
111
|
+
not_found = []
|
|
112
|
+
for target in targets:
|
|
113
|
+
matches = _resolve_target(target, states)
|
|
114
|
+
if not matches:
|
|
115
|
+
not_found.append(target)
|
|
116
|
+
continue
|
|
117
|
+
if len(matches) > 1:
|
|
118
|
+
click.echo(f"Ambiguous: '{target}' matches multiple tracked items:")
|
|
119
|
+
for state, kid in matches:
|
|
120
|
+
entry = state.get(kid)
|
|
121
|
+
click.echo(f" {kid} ({entry.filename})")
|
|
122
|
+
click.echo(" Re-run with the specific knowledge ID.")
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
state, kid = matches[0]
|
|
125
|
+
entry = state.get(kid)
|
|
126
|
+
state.untrack(kid)
|
|
127
|
+
state.mark_unlinked(kid)
|
|
128
|
+
state.save()
|
|
129
|
+
untracked.append((kid, entry.filename))
|
|
130
|
+
|
|
131
|
+
if untracked:
|
|
132
|
+
click.echo(f"Untracked {len(untracked)} item(s):")
|
|
133
|
+
for kid, name in untracked:
|
|
134
|
+
click.echo(f" {name} ({kid})")
|
|
135
|
+
click.echo(f" Local files and remote items unchanged in {sync_dir}/.")
|
|
136
|
+
if not_found:
|
|
137
|
+
click.echo(f"Not tracked: {', '.join(not_found)}", err=True)
|
|
138
|
+
raise SystemExit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _apply_pattern_action(states, patterns, *, action, sync_dir):
|
|
142
|
+
"""Apply ``exclude`` or ``include`` to each pattern across all states.
|
|
143
|
+
|
|
144
|
+
Returns ``[(pattern, outcome)]`` where outcome is one of
|
|
145
|
+
``now_skipped``, ``now_synced``, ``already_skipped``,
|
|
146
|
+
``already_synced``, or ``no_effect``.
|
|
147
|
+
|
|
148
|
+
Strict inverses with last-call-wins semantics *and* exact-cancel:
|
|
149
|
+
when the new action's inverse is already in state we simply remove
|
|
150
|
+
it instead of layering an opposite on top. Net effect per pattern:
|
|
151
|
+
|
|
152
|
+
- prior ``foo`` + ``include foo`` → state minus ``foo``
|
|
153
|
+
- prior ``!foo`` + ``exclude foo`` → state minus ``!foo``
|
|
154
|
+
- empty + ``include foo`` → state plus ``!foo`` (only if
|
|
155
|
+
the pattern actually matches
|
|
156
|
+
a currently-excluded local
|
|
157
|
+
file; otherwise ``no_effect``
|
|
158
|
+
with no state change)
|
|
159
|
+
- empty + ``exclude foo`` → state plus ``foo``
|
|
160
|
+
|
|
161
|
+
Keeps the state at most one entry per pattern, no leftover
|
|
162
|
+
re-include or exclude after a toggle, and no state bloat from
|
|
163
|
+
``include`` calls that wouldn't have changed anything.
|
|
164
|
+
"""
|
|
165
|
+
outcomes = []
|
|
166
|
+
for state, _tid in states:
|
|
167
|
+
for pat in patterns:
|
|
168
|
+
negated = f"!{pat}"
|
|
169
|
+
current = list(state.exclude_patterns)
|
|
170
|
+
if action == "exclude":
|
|
171
|
+
if negated in current:
|
|
172
|
+
# Cancel a prior include — just remove the override,
|
|
173
|
+
# don't also add the exclude. Defaults re-cover the
|
|
174
|
+
# path if the override existed to override one; if
|
|
175
|
+
# not, both calls were no-ops on observable behavior.
|
|
176
|
+
state.remove_excludes([negated])
|
|
177
|
+
state.save()
|
|
178
|
+
outcomes.append((pat, "now_skipped"))
|
|
179
|
+
elif pat in current:
|
|
180
|
+
outcomes.append((pat, "already_skipped"))
|
|
181
|
+
else:
|
|
182
|
+
state.add_excludes([pat])
|
|
183
|
+
state.save()
|
|
184
|
+
outcomes.append((pat, "now_skipped"))
|
|
185
|
+
else:
|
|
186
|
+
if pat in current:
|
|
187
|
+
# Cancel a prior exclude — defaults take over.
|
|
188
|
+
state.remove_excludes([pat])
|
|
189
|
+
state.save()
|
|
190
|
+
outcomes.append((pat, "now_synced"))
|
|
191
|
+
elif negated in current:
|
|
192
|
+
outcomes.append((pat, "already_synced"))
|
|
193
|
+
elif _include_has_effect(sync_dir, pat, current):
|
|
194
|
+
state.add_excludes([negated])
|
|
195
|
+
state.save()
|
|
196
|
+
outcomes.append((pat, "now_synced"))
|
|
197
|
+
else:
|
|
198
|
+
# No currently-excluded file matches this pattern;
|
|
199
|
+
# adding ``!pat`` would be state bloat with zero
|
|
200
|
+
# observable effect. Warn and leave state alone.
|
|
201
|
+
outcomes.append((pat, "no_effect"))
|
|
202
|
+
return outcomes
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _include_has_effect(sync_dir: Path, pattern: str, current_excludes: list[str]) -> bool:
|
|
206
|
+
"""Return True if any local file currently excluded would match ``pattern``.
|
|
207
|
+
|
|
208
|
+
Conservative: only files that physically exist under ``sync_dir``
|
|
209
|
+
count. A pattern aimed at a file that doesn't exist yet is treated
|
|
210
|
+
as no-op — the user can re-run after the file lands. Walks
|
|
211
|
+
``sync_dir`` once per call; cost is negligible compared to a real
|
|
212
|
+
sync's network round trips.
|
|
213
|
+
"""
|
|
214
|
+
matcher = ExclusionMatcher(sync_dir, state_excludes=current_excludes)
|
|
215
|
+
pattern_spec = pathspec.PathSpec.from_lines(GitWildMatchPattern, [pattern])
|
|
216
|
+
for path in Path(sync_dir).rglob("*"):
|
|
217
|
+
if path.is_dir() or path.is_symlink():
|
|
218
|
+
continue
|
|
219
|
+
rel = path.relative_to(sync_dir).as_posix()
|
|
220
|
+
if matcher.excludes(rel) and pattern_spec.match_file(rel):
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _emit_outcomes(sync_dir, results, *, action):
|
|
226
|
+
"""User-facing summary. No mention of state mechanics or `!pattern`."""
|
|
227
|
+
by_outcome: dict[str, set[str]] = {}
|
|
228
|
+
for pat, outcome in results:
|
|
229
|
+
by_outcome.setdefault(outcome, set()).add(pat)
|
|
230
|
+
|
|
231
|
+
changed_any = False
|
|
232
|
+
if action == "exclude":
|
|
233
|
+
if by_outcome.get("now_skipped"):
|
|
234
|
+
click.echo(f"Skipping: {', '.join(sorted(by_outcome['now_skipped']))}")
|
|
235
|
+
changed_any = True
|
|
236
|
+
if by_outcome.get("already_skipped"):
|
|
237
|
+
click.echo(f"Already skipping: {', '.join(sorted(by_outcome['already_skipped']))}")
|
|
238
|
+
else:
|
|
239
|
+
if by_outcome.get("now_synced"):
|
|
240
|
+
click.echo(f"Now syncing: {', '.join(sorted(by_outcome['now_synced']))}")
|
|
241
|
+
changed_any = True
|
|
242
|
+
if by_outcome.get("already_synced"):
|
|
243
|
+
click.echo(f"Already syncing: {', '.join(sorted(by_outcome['already_synced']))}")
|
|
244
|
+
if by_outcome.get("no_effect"):
|
|
245
|
+
pats = sorted(by_outcome["no_effect"])
|
|
246
|
+
click.echo(f"No matching skipped file(s) for: {', '.join(pats)}", err=True)
|
|
247
|
+
click.echo(" Nothing to include. Run again after the file appears, or check spelling.")
|
|
248
|
+
|
|
249
|
+
if changed_any:
|
|
250
|
+
click.echo(f" Takes effect on next 'obris sync' in {sync_dir}/.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _list_settings(states):
|
|
254
|
+
"""Print currently-configured rules for each state, grouped by intent."""
|
|
255
|
+
for state, tid in states:
|
|
256
|
+
current = state.exclude_patterns
|
|
257
|
+
header = f"Settings for {tid}:" if len(states) > 1 else "Current settings:"
|
|
258
|
+
click.echo(header)
|
|
259
|
+
if not current:
|
|
260
|
+
click.echo(" (none — built-in defaults apply)")
|
|
261
|
+
continue
|
|
262
|
+
skips = [p for p in current if not p.startswith("!")]
|
|
263
|
+
forced_syncs = [p[1:] for p in current if p.startswith("!")]
|
|
264
|
+
if skips:
|
|
265
|
+
click.echo(" Skipping:")
|
|
266
|
+
for p in skips:
|
|
267
|
+
click.echo(f" {p}")
|
|
268
|
+
if forced_syncs:
|
|
269
|
+
click.echo(" Always syncing:")
|
|
270
|
+
for p in forced_syncs:
|
|
271
|
+
click.echo(f" {p}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_target(target, states):
|
|
275
|
+
"""Resolve ``target`` (kid or filename) to a list of (state, kid) matches.
|
|
276
|
+
|
|
277
|
+
Filename lookup is by basename only (matches TrackedItem.filename).
|
|
278
|
+
Returns a list because the same filename can exist under different
|
|
279
|
+
topics in the same dir; the caller decides whether to error on
|
|
280
|
+
ambiguity.
|
|
281
|
+
"""
|
|
282
|
+
matches = []
|
|
283
|
+
for state, _tid in states:
|
|
284
|
+
if state.is_tracked(target):
|
|
285
|
+
matches.append((state, target))
|
|
286
|
+
continue
|
|
287
|
+
kid, _entry = state.find_by_filename(target)
|
|
288
|
+
if kid:
|
|
289
|
+
matches.append((state, kid))
|
|
290
|
+
return matches
|
|
@@ -143,9 +143,15 @@ def run_sync(
|
|
|
143
143
|
if filter_item_ids and item.id not in filter_item_ids:
|
|
144
144
|
continue
|
|
145
145
|
|
|
146
|
+
# On a dry-run pass, ``reconcile_topic_dirs`` deliberately doesn't
|
|
147
|
+
# call ``state.set_topic_dir`` (no side effects on the local state
|
|
148
|
+
# file), so ``state.get_topic_dir`` returns None for any subtopic
|
|
149
|
+
# that the live run would create. Fall back to the desired map
|
|
150
|
+
# the manifest already gave us, otherwise dry-run output prints
|
|
151
|
+
# bare basenames with no parent directory context.
|
|
146
152
|
item_rel = state.get_topic_dir(item.topic_id) if item.topic_id else ""
|
|
147
153
|
if item_rel is None:
|
|
148
|
-
item_rel = ""
|
|
154
|
+
item_rel = desired_topic_dirs.get(item.topic_id, "") if item.topic_id else ""
|
|
149
155
|
|
|
150
156
|
if state.is_tracked(item.id):
|
|
151
157
|
entry = state.get(item.id)
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
"""File-level exclusion matcher for sync.
|
|
2
2
|
|
|
3
|
-
Combines
|
|
4
|
-
``gitwildmatch``):
|
|
5
|
-
|
|
6
|
-
1. **Built-in defaults** —
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
``
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
3
|
+
Combines two pattern sources, evaluated in order, gitignore-style
|
|
4
|
+
(``pathspec``'s ``gitwildmatch``):
|
|
5
|
+
|
|
6
|
+
1. **Built-in defaults** — hardcoded in this module. Hides VCS
|
|
7
|
+
metadata, dependency dirs, OS / editor cruft, and credential-shaped
|
|
8
|
+
files.
|
|
9
|
+
2. **State-level rules** — set via ``obris sync exclude`` and
|
|
10
|
+
``obris sync include``. Stored as a list of patterns on each
|
|
11
|
+
``SyncState``; ``!pattern`` entries are re-includes that override
|
|
12
|
+
the defaults. The CLI is the only writer.
|
|
13
|
+
|
|
14
|
+
Last match wins (gitignore semantics), so state-level rules can
|
|
15
|
+
always override defaults. Users never hand-edit a config file —
|
|
16
|
+
state is owned by the CLI.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -22,6 +24,11 @@ import pathspec
|
|
|
22
24
|
from pathspec.patterns.gitwildmatch import GitWildMatchPattern
|
|
23
25
|
|
|
24
26
|
DEFAULT_EXCLUDES = [
|
|
27
|
+
# Obris's own in-dir state. Lives at ``<sync_dir>/.obris/`` and is
|
|
28
|
+
# never knowledge content. Self-excludes to avoid an infinite-loop
|
|
29
|
+
# of "the engine sees state files, would push them, server stores
|
|
30
|
+
# them, sync sees the new items..."
|
|
31
|
+
".obris/",
|
|
25
32
|
# VCS
|
|
26
33
|
".git/",
|
|
27
34
|
".hg/",
|
|
@@ -51,21 +58,22 @@ DEFAULT_EXCLUDES = [
|
|
|
51
58
|
# Temp
|
|
52
59
|
"*.tmp",
|
|
53
60
|
"*.bak",
|
|
61
|
+
# Secrets — common files / dirs that hold credentials. Kept narrow
|
|
62
|
+
# to literal known-bad paths instead of a blanket dotfile rule so
|
|
63
|
+
# AI-tool config dirs (.claude/, .cursor/, .github/, etc.) keep
|
|
64
|
+
# syncing. Users can override any default with
|
|
65
|
+
# ``obris sync include <pattern>``.
|
|
66
|
+
".env",
|
|
67
|
+
".env.*",
|
|
68
|
+
".envrc",
|
|
69
|
+
".netrc",
|
|
70
|
+
".npmrc",
|
|
71
|
+
".pypirc",
|
|
72
|
+
".aws/",
|
|
73
|
+
".gnupg/",
|
|
74
|
+
".ssh/",
|
|
54
75
|
]
|
|
55
76
|
|
|
56
|
-
OBRISIGNORE = ".obrisignore"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _read_obrisignore(sync_dir: Path) -> list[str]:
|
|
60
|
-
path = sync_dir / OBRISIGNORE
|
|
61
|
-
if not path.exists():
|
|
62
|
-
return []
|
|
63
|
-
try:
|
|
64
|
-
lines = path.read_text(encoding="utf-8").splitlines()
|
|
65
|
-
except OSError:
|
|
66
|
-
return []
|
|
67
|
-
return [line for line in lines if line.strip() and not line.strip().startswith("#")]
|
|
68
|
-
|
|
69
77
|
|
|
70
78
|
class ExclusionMatcher:
|
|
71
79
|
"""Single matcher built once per sync pass, queried per file.
|
|
@@ -80,8 +88,6 @@ class ExclusionMatcher:
|
|
|
80
88
|
self._sources: list[tuple[str, str]] = []
|
|
81
89
|
for pat in DEFAULT_EXCLUDES:
|
|
82
90
|
self._sources.append(("default", pat))
|
|
83
|
-
for pat in _read_obrisignore(self.sync_dir):
|
|
84
|
-
self._sources.append((OBRISIGNORE, pat))
|
|
85
91
|
for pat in state_excludes or []:
|
|
86
92
|
self._sources.append(("state", pat))
|
|
87
93
|
self._spec = pathspec.PathSpec.from_lines(GitWildMatchPattern, [pat for _, pat in self._sources])
|
|
@@ -94,8 +100,8 @@ class ExclusionMatcher:
|
|
|
94
100
|
"""Return ``[(source_label, pattern), ...]`` for every pattern that matches.
|
|
95
101
|
|
|
96
102
|
A path can match multiple patterns; this returns all of them in
|
|
97
|
-
source order (defaults →
|
|
98
|
-
|
|
103
|
+
source order (defaults → state). Used for diagnostics, not
|
|
104
|
+
for the actual exclude decision.
|
|
99
105
|
"""
|
|
100
106
|
matched = []
|
|
101
107
|
for label, pat in self._sources:
|
|
@@ -115,7 +115,7 @@ def run_sync_pass(
|
|
|
115
115
|
"remote_updated_at": meta.get("remote_updated_at"),
|
|
116
116
|
}
|
|
117
117
|
)
|
|
118
|
-
_emit_scan(scan, sync_dir, add_all_files, verbose=verbose)
|
|
118
|
+
_emit_scan(scan, sync_dir, add_all_files, dry_run=dry_run, verbose=verbose)
|
|
119
119
|
|
|
120
120
|
if add_all_files and scan.untracked and not dry_run:
|
|
121
121
|
# All targets share the same sync_dir; bulk-add against the
|
|
@@ -141,14 +141,29 @@ def run_sync_pass(
|
|
|
141
141
|
return totals
|
|
142
142
|
|
|
143
143
|
|
|
144
|
-
def _emit_scan(scan, sync_dir, add_all_files, *, verbose=False):
|
|
144
|
+
def _emit_scan(scan, sync_dir, add_all_files, *, dry_run=False, verbose=False):
|
|
145
145
|
if not scan.untracked and not scan.symlinks and not (verbose and scan.excluded):
|
|
146
146
|
return
|
|
147
147
|
cap = None if verbose else _LIST_PREVIEW
|
|
148
148
|
if scan.untracked:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
# Output is shaped by the (dry_run, add_all_files) combination so
|
|
150
|
+
# the user always knows whether the listed files would actually
|
|
151
|
+
# be uploaded:
|
|
152
|
+
# dry-run + --add-all → preview header ("Would add ...")
|
|
153
|
+
# dry-run, no --add-all → preview header ("would not add")
|
|
154
|
+
# live + --add-all → silent here (the real add path prints
|
|
155
|
+
# its own "Added N file(s)" summary)
|
|
156
|
+
# live, no --add-all → the existing "Choose how to proceed"
|
|
157
|
+
# prompt with the add/skip recipes.
|
|
158
|
+
if dry_run and add_all_files:
|
|
159
|
+
click.echo(f"\nWould add {len(scan.untracked)} file(s):")
|
|
160
|
+
_print_capped(scan.untracked, cap)
|
|
161
|
+
elif dry_run:
|
|
162
|
+
click.echo(f"\n{len(scan.untracked)} untracked file(s) (would not add — re-run with --add-all):")
|
|
163
|
+
_print_capped(scan.untracked, cap)
|
|
164
|
+
elif not add_all_files:
|
|
165
|
+
click.echo(f"\n{len(scan.untracked)} untracked file(s) in {sync_dir}/:")
|
|
166
|
+
_print_capped(scan.untracked, cap)
|
|
152
167
|
click.echo(" Choose how to proceed:")
|
|
153
168
|
click.echo(" Upload all: obris sync --add-all")
|
|
154
169
|
click.echo(" Upload some: obris sync add <file>...")
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
"""Sync state persistence and manipulation.
|
|
2
2
|
|
|
3
|
-
State is stored
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
State is stored in-place: ``<sync_dir>/.obris/<topic_id>.json``, one
|
|
4
|
+
file per root topic. When the user deletes the sync dir, state goes
|
|
5
|
+
with it — clean lifecycle, no orphaned global state, no cross-machine
|
|
6
|
+
collisions when two laptops happen to share a path. Mirrors git's
|
|
7
|
+
``.git/`` pattern.
|
|
8
|
+
|
|
9
|
+
State is keyed by knowledge_id (the remote item's primary key), not
|
|
10
|
+
by filename. Renames on either side are metadata updates, not
|
|
11
|
+
create+delete.
|
|
6
12
|
|
|
7
13
|
Subtopic support: one state file per *root* topic covers the entire
|
|
8
14
|
subtree. topic_dirs maps topic_id -> relative directory (POSIX-style)
|
|
@@ -10,26 +16,39 @@ under local_path. The CLI only syncs root topics (parent_id = NULL);
|
|
|
10
16
|
child topics are not valid sync targets on their own.
|
|
11
17
|
"""
|
|
12
18
|
|
|
13
|
-
import hashlib
|
|
14
19
|
import json
|
|
15
20
|
from pathlib import Path
|
|
16
21
|
|
|
17
|
-
from obris.config import CONFIG_DIR
|
|
18
|
-
|
|
19
22
|
from .mapping import now_iso
|
|
20
23
|
from .models import TrackedItem
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
# In-dir state directory name. Excluded by ``DEFAULT_EXCLUDES`` so the
|
|
26
|
+
# engine never tries to sync state files as knowledge.
|
|
27
|
+
STATE_DIR_NAME = ".obris"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _state_dir(local_path) -> Path:
|
|
31
|
+
return Path(local_path).resolve() / STATE_DIR_NAME
|
|
32
|
+
|
|
23
33
|
|
|
34
|
+
def _state_path(topic_id, local_path) -> Path:
|
|
35
|
+
return _state_dir(local_path) / f"{topic_id}.json"
|
|
24
36
|
|
|
25
|
-
def _state_key(topic_id, local_path):
|
|
26
|
-
"""Deterministic short hash for a (topic_id, local_path) pair."""
|
|
27
|
-
raw = f"{topic_id}:{Path(local_path).resolve()}"
|
|
28
|
-
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
29
37
|
|
|
38
|
+
def _ensure_state_dir(local_path) -> Path:
|
|
39
|
+
"""Create ``<sync_dir>/.obris/`` and seed a self-excluding ``.gitignore``.
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
The ``*`` line keeps the state dir out of git/svn checkouts of
|
|
42
|
+
the user's project — same trick git uses for ``.git/info``.
|
|
43
|
+
Idempotent: ``mkdir(parents=True, exist_ok=True)`` and we only
|
|
44
|
+
write the gitignore when missing.
|
|
45
|
+
"""
|
|
46
|
+
state_dir = _state_dir(local_path)
|
|
47
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
gitignore = state_dir / ".gitignore"
|
|
49
|
+
if not gitignore.exists():
|
|
50
|
+
gitignore.write_text("*\n")
|
|
51
|
+
return state_dir
|
|
33
52
|
|
|
34
53
|
|
|
35
54
|
def _read(path):
|
|
@@ -74,21 +93,24 @@ class SyncState:
|
|
|
74
93
|
def find_all_for_path(cls, local_path):
|
|
75
94
|
"""Find all states for the given local path.
|
|
76
95
|
|
|
77
|
-
|
|
96
|
+
Walks ``<local_path>/.obris/*.json``. Returns ``[]`` when the
|
|
97
|
+
state dir doesn't exist yet (fresh, never-synced directory).
|
|
78
98
|
"""
|
|
79
|
-
resolved =
|
|
80
|
-
|
|
99
|
+
resolved = Path(local_path).resolve()
|
|
100
|
+
state_dir = resolved / STATE_DIR_NAME
|
|
101
|
+
if not state_dir.exists():
|
|
81
102
|
return []
|
|
82
103
|
results = []
|
|
83
|
-
for f in
|
|
104
|
+
for f in sorted(state_dir.glob("*.json")):
|
|
84
105
|
data = _read(f)
|
|
85
|
-
if
|
|
86
|
-
|
|
87
|
-
|
|
106
|
+
if not data:
|
|
107
|
+
continue
|
|
108
|
+
topic_id = data.get("topic_id") or f.stem
|
|
109
|
+
results.append((cls(topic_id, local_path, data), topic_id))
|
|
88
110
|
return results
|
|
89
111
|
|
|
90
112
|
def save(self):
|
|
91
|
-
|
|
113
|
+
_ensure_state_dir(self.local_path)
|
|
92
114
|
path = _state_path(self.topic_id, self.local_path)
|
|
93
115
|
tmp = path.with_suffix(".tmp")
|
|
94
116
|
data = {
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
"""Per-checkout sync configuration subcommands.
|
|
2
|
-
|
|
3
|
-
Hosts ``exclude`` / ``include`` / ``untrack`` — commands that modify
|
|
4
|
-
state-file metadata for a synced directory. Kept out of
|
|
5
|
-
``commands.sync`` so the sync group definition + invocation logic
|
|
6
|
-
stays under the 300-line cap and these subcommands stay together
|
|
7
|
-
because they share the ``_states_for_current_dir`` resolver.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
import click
|
|
15
|
-
|
|
16
|
-
from obris.sync.state import SyncState
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _states_for_current_dir(path):
|
|
20
|
-
"""Return ``(sync_dir, states)`` for ``path``, exiting cleanly if unsynced."""
|
|
21
|
-
sync_dir = Path(path).resolve()
|
|
22
|
-
states = SyncState.find_all_for_path(sync_dir)
|
|
23
|
-
if not states:
|
|
24
|
-
raise SystemExit(f"No synced topic found for {sync_dir}/. Run 'obris sync --topic <id>' first.")
|
|
25
|
-
return sync_dir, states
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def register(sync):
|
|
29
|
-
"""Attach the config subcommands to the given Click group."""
|
|
30
|
-
|
|
31
|
-
@sync.command("exclude")
|
|
32
|
-
@click.argument("patterns", nargs=-1)
|
|
33
|
-
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
34
|
-
@click.option("--list", "list_only", is_flag=True, help="Print current exclude patterns and exit")
|
|
35
|
-
def sync_exclude(patterns, path, list_only):
|
|
36
|
-
"""Add file patterns to skip during sync.
|
|
37
|
-
|
|
38
|
-
Patterns use gitignore-style syntax. Examples:
|
|
39
|
-
|
|
40
|
-
\b
|
|
41
|
-
obris sync exclude '*.draft.md' # any markdown draft
|
|
42
|
-
obris sync exclude scratch/ # entire directory
|
|
43
|
-
obris sync exclude notes/private.md # specific file
|
|
44
|
-
obris sync exclude --list # show current patterns
|
|
45
|
-
|
|
46
|
-
Patterns apply to all topics syncing this directory. Idempotent —
|
|
47
|
-
adding a pattern twice is a no-op. Remove patterns with
|
|
48
|
-
'obris sync include <pattern>'.
|
|
49
|
-
"""
|
|
50
|
-
sync_dir, states = _states_for_current_dir(path)
|
|
51
|
-
|
|
52
|
-
if list_only:
|
|
53
|
-
if patterns:
|
|
54
|
-
raise click.UsageError("Cannot combine --list with patterns.")
|
|
55
|
-
for state, tid in states:
|
|
56
|
-
current = state.exclude_patterns
|
|
57
|
-
header = f"Excludes for {tid}:" if len(states) > 1 else "Current excludes:"
|
|
58
|
-
click.echo(header)
|
|
59
|
-
if not current:
|
|
60
|
-
click.echo(" (none)")
|
|
61
|
-
else:
|
|
62
|
-
for p in current:
|
|
63
|
-
click.echo(f" {p}")
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
if not patterns:
|
|
67
|
-
raise click.UsageError("Provide one or more patterns, or use --list.")
|
|
68
|
-
|
|
69
|
-
added_total = []
|
|
70
|
-
for state, _tid in states:
|
|
71
|
-
added = state.add_excludes(patterns)
|
|
72
|
-
if added:
|
|
73
|
-
state.save()
|
|
74
|
-
added_total.extend(added)
|
|
75
|
-
|
|
76
|
-
unique_added = sorted(set(added_total))
|
|
77
|
-
skipped = sorted(set(patterns) - set(unique_added))
|
|
78
|
-
if unique_added:
|
|
79
|
-
click.echo(f"Added {len(unique_added)} pattern(s): {', '.join(unique_added)}")
|
|
80
|
-
click.echo(f" Excludes apply on next 'obris sync' in {sync_dir}/.")
|
|
81
|
-
if skipped:
|
|
82
|
-
click.echo(f"Already present: {', '.join(skipped)}")
|
|
83
|
-
|
|
84
|
-
@sync.command("include")
|
|
85
|
-
@click.argument("patterns", nargs=-1, required=True)
|
|
86
|
-
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
87
|
-
def sync_include(patterns, path):
|
|
88
|
-
"""Remove file patterns from the exclude list (re-enable syncing).
|
|
89
|
-
|
|
90
|
-
Inverse of 'obris sync exclude'. Idempotent — removing a pattern
|
|
91
|
-
that isn't present is a no-op with a notice.
|
|
92
|
-
|
|
93
|
-
obris sync include '*.draft.md'
|
|
94
|
-
"""
|
|
95
|
-
sync_dir, states = _states_for_current_dir(path)
|
|
96
|
-
|
|
97
|
-
removed_total = []
|
|
98
|
-
for state, _tid in states:
|
|
99
|
-
removed = state.remove_excludes(patterns)
|
|
100
|
-
if removed:
|
|
101
|
-
state.save()
|
|
102
|
-
removed_total.extend(removed)
|
|
103
|
-
|
|
104
|
-
unique_removed = sorted(set(removed_total))
|
|
105
|
-
not_present = sorted(set(patterns) - set(unique_removed))
|
|
106
|
-
if unique_removed:
|
|
107
|
-
click.echo(f"Removed {len(unique_removed)} pattern(s): {', '.join(unique_removed)}")
|
|
108
|
-
click.echo(f" Will sync on next 'obris sync' in {sync_dir}/.")
|
|
109
|
-
if not_present:
|
|
110
|
-
click.echo(f"Not in exclude list: {', '.join(not_present)}")
|
|
111
|
-
|
|
112
|
-
@sync.command("untrack")
|
|
113
|
-
@click.argument("targets", nargs=-1, required=True)
|
|
114
|
-
@click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
|
|
115
|
-
def sync_untrack(targets, path):
|
|
116
|
-
"""Stop syncing items without deleting either side.
|
|
117
|
-
|
|
118
|
-
Each TARGET is a knowledge ID or a tracked filename (basename).
|
|
119
|
-
Removes the sync link; both the local file and the remote item stay
|
|
120
|
-
in place. Subsequent 'obris sync' calls will not re-pull these items.
|
|
121
|
-
|
|
122
|
-
\b
|
|
123
|
-
obris sync untrack abc123
|
|
124
|
-
obris sync untrack notes.md draft.md
|
|
125
|
-
|
|
126
|
-
Re-link later with 'obris sync link <file> -i <id>' or 'obris sync add'.
|
|
127
|
-
Permanently delete the remote with 'obris knowledge delete <id>'.
|
|
128
|
-
"""
|
|
129
|
-
sync_dir, states = _states_for_current_dir(path)
|
|
130
|
-
|
|
131
|
-
untracked = []
|
|
132
|
-
not_found = []
|
|
133
|
-
for target in targets:
|
|
134
|
-
matches = _resolve_target(target, states)
|
|
135
|
-
if not matches:
|
|
136
|
-
not_found.append(target)
|
|
137
|
-
continue
|
|
138
|
-
if len(matches) > 1:
|
|
139
|
-
click.echo(f"Ambiguous: '{target}' matches multiple tracked items:")
|
|
140
|
-
for state, kid in matches:
|
|
141
|
-
entry = state.get(kid)
|
|
142
|
-
click.echo(f" {kid} ({entry.filename})")
|
|
143
|
-
click.echo(" Re-run with the specific knowledge ID.")
|
|
144
|
-
raise SystemExit(1)
|
|
145
|
-
state, kid = matches[0]
|
|
146
|
-
entry = state.get(kid)
|
|
147
|
-
state.untrack(kid)
|
|
148
|
-
state.mark_unlinked(kid)
|
|
149
|
-
state.save()
|
|
150
|
-
untracked.append((kid, entry.filename))
|
|
151
|
-
|
|
152
|
-
if untracked:
|
|
153
|
-
click.echo(f"Untracked {len(untracked)} item(s):")
|
|
154
|
-
for kid, name in untracked:
|
|
155
|
-
click.echo(f" {name} ({kid})")
|
|
156
|
-
click.echo(f" Local files and remote items unchanged in {sync_dir}/.")
|
|
157
|
-
if not_found:
|
|
158
|
-
click.echo(f"Not tracked: {', '.join(not_found)}", err=True)
|
|
159
|
-
raise SystemExit(1)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _resolve_target(target, states):
|
|
163
|
-
"""Resolve ``target`` (kid or filename) to a list of (state, kid) matches.
|
|
164
|
-
|
|
165
|
-
Filename lookup is by basename only (matches TrackedItem.filename).
|
|
166
|
-
Returns a list because the same filename can exist under different
|
|
167
|
-
topics in the same dir; the caller decides whether to error on
|
|
168
|
-
ambiguity.
|
|
169
|
-
"""
|
|
170
|
-
matches = []
|
|
171
|
-
for state, _tid in states:
|
|
172
|
-
if state.is_tracked(target):
|
|
173
|
-
matches.append((state, target))
|
|
174
|
-
continue
|
|
175
|
-
kid, _entry = state.find_by_filename(target)
|
|
176
|
-
if kid:
|
|
177
|
-
matches.append((state, kid))
|
|
178
|
-
return matches
|
|
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
|