obris-cli 0.6.2__tar.gz → 0.7.3__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.
Files changed (51) hide show
  1. {obris_cli-0.6.2 → obris_cli-0.7.3}/PKG-INFO +1 -1
  2. {obris_cli-0.6.2 → obris_cli-0.7.3}/README.md +1 -0
  3. {obris_cli-0.6.2 → obris_cli-0.7.3}/pyproject.toml +1 -1
  4. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/sync.py +13 -2
  5. obris_cli-0.7.3/src/obris/commands/sync_config.py +290 -0
  6. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/run.py +8 -2
  7. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/tracked.py +1 -1
  8. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/exclusions.py +35 -29
  9. obris_cli-0.7.3/src/obris/sync/preview.py +109 -0
  10. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/runner.py +71 -92
  11. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/state.py +44 -22
  12. {obris_cli-0.6.2 → obris_cli-0.7.3}/uv.lock +1 -1
  13. obris_cli-0.6.2/src/obris/commands/sync_config.py +0 -178
  14. {obris_cli-0.6.2 → obris_cli-0.7.3}/.claude/settings.local.json +0 -0
  15. {obris_cli-0.6.2 → obris_cli-0.7.3}/.gitignore +0 -0
  16. {obris_cli-0.6.2 → obris_cli-0.7.3}/ruff.toml +0 -0
  17. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/__init__.py +0 -0
  18. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/__init__.py +0 -0
  19. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/client.py +0 -0
  20. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/knowledge.py +0 -0
  21. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/topics.py +0 -0
  22. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/assets/icon.png +0 -0
  23. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/auth/__init__.py +0 -0
  24. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/auth/session.py +0 -0
  25. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/cli.py +0 -0
  26. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/__init__.py +0 -0
  27. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/auth.py +0 -0
  28. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/env.py +0 -0
  29. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/knowledge.py +0 -0
  30. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/save.py +0 -0
  31. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/sync_conflicts.py +0 -0
  32. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/topic.py +0 -0
  33. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/config.py +0 -0
  34. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/output.py +0 -0
  35. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/routes.py +0 -0
  36. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/__init__.py +0 -0
  37. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/commands.py +0 -0
  38. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/constants.py +0 -0
  39. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/__init__.py +0 -0
  40. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/filters.py +0 -0
  41. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/manifest.py +0 -0
  42. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/subtree.py +0 -0
  43. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/io.py +0 -0
  44. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/mapping.py +0 -0
  45. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/models.py +0 -0
  46. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/resolver.py +0 -0
  47. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/scanner.py +0 -0
  48. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/__init__.py +0 -0
  49. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/capture.py +0 -0
  50. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/notify.py +0 -0
  51. {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/upload.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obris-cli
3
- Version: 0.6.2
3
+ Version: 0.7.3
4
4
  Summary: Save, organize, and access your knowledge from the command line
5
5
  Project-URL: Homepage, https://obris.ai
6
6
  Project-URL: Repository, https://github.com/obris-dev/obris
@@ -43,6 +43,7 @@ Opens a browser to log in. The CLI waits, you authorize, done. Works from any ma
43
43
  | `obris sync [path]` | Sync a directory with an Obris topic |
44
44
  | `obris sync add <file>` | Add a local file to a synced topic |
45
45
  | `obris sync link <file> -i <id>` | Relink a renamed file |
46
+ | `obris sync unlink <file-or-id>` | Break the local-to-remote sync link |
46
47
  | `obris topic list` | List all topics |
47
48
  | `obris topic view <id>` | View a topic and its knowledge items |
48
49
  | `obris knowledge view <id>` | View a knowledge item |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "obris-cli"
3
- version = "0.6.2"
3
+ version = "0.7.3"
4
4
  description = "Save, organize, and access your knowledge from the command line"
5
5
 
6
6
  requires-python = ">=3.10"
@@ -7,8 +7,9 @@ from obris.commands.sync_config import register as _register_config_subcommands
7
7
  from obris.commands.sync_conflicts import register as _register_conflicts_subgroup
8
8
  from obris.output import as_json, is_json
9
9
  from obris.sync.commands import add_file, link_file
10
+ from obris.sync.preview import preview_first_sync
10
11
  from obris.sync.resolver import assert_all_roots, find_root_id, resolve_targets
11
- from obris.sync.runner import preview_first_sync, run_sync_pass
12
+ from obris.sync.runner import run_sync_pass
12
13
  from obris.sync.state import SyncState
13
14
 
14
15
 
@@ -68,6 +69,15 @@ def sync(ctx, path, topic_id, item_ids, include_patterns, dry_run, add_all_files
68
69
 
69
70
  targets = resolve_targets(sync_dir, topic_id, no_create=no_create, dry_run=dry_run)
70
71
 
72
+ if add_all_files and len(targets) > 1:
73
+ topic_ids = [t[1] for t in targets]
74
+ raise click.UsageError(
75
+ "--add-all is ambiguous when multiple topics are linked to this "
76
+ "directory. Pass --topic <id> to scope it to one topic, or use "
77
+ "'obris sync add <file> -t <id>' to add files one at a time.\n"
78
+ f" Linked topics: {', '.join(topic_ids)}"
79
+ )
80
+
71
81
  if not targets:
72
82
  # dry-run + no state + no --topic: preview the bootstrap +
73
83
  # initial-add locally without creating a phantom server topic.
@@ -102,6 +112,7 @@ def sync(ctx, path, topic_id, item_ids, include_patterns, dry_run, add_all_files
102
112
  "symlinks": totals["symlinks"],
103
113
  "conflicts_pending": totals["conflicts_pending"],
104
114
  "missing_local": totals["missing_local"],
115
+ "skipped_by_include": totals.get("skipped_by_include", []),
105
116
  }
106
117
  )
107
118
 
@@ -213,7 +224,7 @@ def sync_link(file, item_id, topic_id):
213
224
  click.echo(f'Linked "{filepath.name}" to item {item_id}')
214
225
 
215
226
 
216
- # Per-checkout configuration subcommands (exclude / include / untrack)
227
+ # Per-checkout configuration subcommands (exclude / include / unlink)
217
228
  # and the conflicts subgroup live in separate modules to keep this
218
229
  # file under the 300-line cap.
219
230
  _register_config_subcommands(sync)
@@ -0,0 +1,290 @@
1
+ """Per-checkout sync configuration subcommands.
2
+
3
+ Hosts ``exclude`` / ``include`` / ``unlink`` — 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("unlink")
92
+ @click.argument("targets", nargs=-1, required=True)
93
+ @click.option("--path", "-p", default=".", help="Sync directory (defaults to current directory)")
94
+ def sync_unlink(targets, path):
95
+ """Break the local-to-remote sync link without deleting either side.
96
+
97
+ Each TARGET is a knowledge ID or a linked 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 unlink abc123
103
+ obris sync unlink 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
+ unlinked = []
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 linked 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
+ unlinked.append((kid, entry.filename))
130
+
131
+ if unlinked:
132
+ click.echo(f"Unlinked {len(unlinked)} item(s):")
133
+ for kid, name in unlinked:
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 linked: {', '.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
@@ -128,7 +128,7 @@ def run_sync(
128
128
  continue
129
129
  seen_ids.add(item.id)
130
130
  if state.is_unlinked(item.id):
131
- # User ran 'obris sync untrack' on this id. Leave the
131
+ # User ran 'obris sync unlink' on this id. Leave the
132
132
  # remote alone, don't pull, don't surface — they
133
133
  # explicitly opted out of syncing this item.
134
134
  continue
@@ -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)
@@ -78,7 +78,7 @@ def sync_tracked_item(topic_id, item, entry, state, sync_dir, desired_rel, *, dr
78
78
  click.echo(f" Missing locally: {display} (knowledge_id {item.id})")
79
79
  click.echo(" Options:")
80
80
  click.echo(f" obris sync link <new-path> -i {item.id} # moved or renamed")
81
- click.echo(f" obris sync untrack {item.id} # keep both copies, stop syncing")
81
+ click.echo(f" obris sync unlink {item.id} # keep both copies, break the link")
82
82
  click.echo(f" obris knowledge delete {item.id} # remove the remote item")
83
83
  return MISSING
84
84
 
@@ -1,17 +1,19 @@
1
1
  """File-level exclusion matcher for sync.
2
2
 
3
- Combines three pattern sources, all gitignore-style (``pathspec``'s
4
- ``gitwildmatch``):
5
-
6
- 1. **Built-in defaults** — always applied, hardcoded in this module.
7
- Hides VCS metadata, dependency dirs, OS / editor cruft.
8
- 2. **``.obrisignore``** — user-authored, lives at the sync-dir root.
9
- The CLI reads it; it never writes to it.
10
- 3. **State-level excludes** per-checkout, set via
11
- ``obris sync exclude``. Plumbed through ``state_excludes`` so this
12
- module has no opinion on where they're persisted.
13
-
14
- Supports gitignore semantics including ``!pattern`` re-includes.
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 → .obrisignore → state). Used for
98
- diagnostics, not for the actual exclude decision.
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:
@@ -0,0 +1,109 @@
1
+ """Local-only dry-run preview for first-time syncs.
2
+
3
+ Used when the user runs ``obris sync --dry-run`` against a directory
4
+ that has no linked topic yet — ``resolve_targets`` returns an empty
5
+ target list rather than creating a phantom topic on the server, and
6
+ the sync command falls through to this preview helper instead of
7
+ the regular ``run_sync_pass``.
8
+
9
+ Side-effect free: walks the dir using a synthetic ``SyncState`` so
10
+ the same exclusion + symlink + tracked-name logic runs as on a real
11
+ sync. Reports what ``create_topic``, ``_ensure_subtopic_path``, and
12
+ ``add_all`` *would* do without touching the server.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path, PurePosixPath
18
+
19
+ import click
20
+
21
+ from obris.sync.scanner import find_untracked
22
+ from obris.sync.state import SyncState
23
+
24
+
25
+ def preview_first_sync(sync_dir: Path, *, add_all_files: bool, allow_subtopics: bool) -> dict:
26
+ """Render a local-only preview of what a first sync would create.
27
+
28
+ Output sections (each only emitted when non-empty):
29
+
30
+ - the would-be root topic name
31
+ - the subtopic structure that would be mirrored from local subdirs
32
+ - untracked files (gated on ``--add-all`` and ``--no-subtopics``)
33
+ - ignored files (always shown — user explicitly asked for the full picture)
34
+ - symlinks (always shown)
35
+
36
+ Returns the same totals dict shape as ``run_sync_pass`` so the
37
+ JSON output stays consistent across both code paths.
38
+ """
39
+ sync_dir = Path(sync_dir).resolve()
40
+ topic_name = sync_dir.name
41
+ click.echo(" (dry run — no changes will be made)")
42
+ click.echo(f' Would create topic "{topic_name}" at {sync_dir}/')
43
+
44
+ synthetic = SyncState("__preview__", str(sync_dir))
45
+ scan = find_untracked(sync_dir, [(synthetic, "__preview__")])
46
+
47
+ # Subtopic projection: every directory segment under sync_dir that
48
+ # contains an untracked file would become a subtopic level. Mirrors
49
+ # the cache walk in ``scanner._ensure_subtopic_path``.
50
+ subdirs: set[str] = set()
51
+ skipped_subdir = 0
52
+ if allow_subtopics:
53
+ for rel in scan.untracked:
54
+ rel_dir = str(PurePosixPath(rel).parent)
55
+ if rel_dir == ".":
56
+ continue
57
+ walked = ""
58
+ for seg in rel_dir.split("/"):
59
+ walked = f"{walked}/{seg}" if walked else seg
60
+ subdirs.add(walked)
61
+ else:
62
+ skipped_subdir = sum(1 for rel in scan.untracked if "/" in rel)
63
+
64
+ if subdirs:
65
+ click.echo(f"\nWould create {len(subdirs)} subtopic(s):")
66
+ for sd in sorted(subdirs):
67
+ click.echo(f" {sd}/")
68
+
69
+ if scan.untracked:
70
+ if not add_all_files:
71
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) (would NOT add — re-run with --add-all):")
72
+ _print_all(scan.untracked)
73
+ elif not allow_subtopics:
74
+ top_level = [r for r in scan.untracked if "/" not in r]
75
+ click.echo(f"\nWould add {len(top_level)} top-level file(s):")
76
+ _print_all(top_level)
77
+ if skipped_subdir:
78
+ click.echo(f"\nWould skip {skipped_subdir} file(s) in subdirs (--no-subtopics):")
79
+ _print_all([r for r in scan.untracked if "/" in r])
80
+ else:
81
+ click.echo(f"\nWould add {len(scan.untracked)} file(s):")
82
+ _print_all(scan.untracked)
83
+
84
+ if scan.excluded:
85
+ click.echo(f"\n{len(scan.excluded)} file(s) matched ignore rules:")
86
+ _print_all(scan.excluded)
87
+
88
+ if scan.symlinks:
89
+ click.echo(f"\n{len(scan.symlinks)} symlink(s) skipped:")
90
+ _print_all([f"{p} -> {t}" for p, t in scan.symlinks])
91
+
92
+ return {
93
+ "pulled": 0,
94
+ "pushed": 0,
95
+ "conflicts": 0,
96
+ "errors": 0,
97
+ "untracked": list(scan.untracked),
98
+ "excluded_count": len(scan.excluded),
99
+ "symlinks": [{"path": p, "target": t} for p, t in scan.symlinks],
100
+ "conflicts_pending": [],
101
+ "missing_local": [],
102
+ "skipped_by_include": [],
103
+ }
104
+
105
+
106
+ def _print_all(items):
107
+ """Print every item, one per line, indented."""
108
+ for line in items:
109
+ click.echo(f" {line}")
@@ -8,6 +8,7 @@ import click
8
8
 
9
9
  from obris.api.topics import get_topic
10
10
  from obris.sync.engine import run_sync
11
+ from obris.sync.engine.filters import any_match
11
12
  from obris.sync.resolver import find_root
12
13
  from obris.sync.scanner import add_all, find_untracked
13
14
  from obris.sync.state import SyncState
@@ -47,6 +48,7 @@ def run_sync_pass(
47
48
  "symlinks": [],
48
49
  "conflicts_pending": [],
49
50
  "missing_local": [],
51
+ "skipped_by_include": [],
50
52
  }
51
53
 
52
54
  if dry_run:
@@ -98,9 +100,23 @@ def run_sync_pass(
98
100
  if not fresh_states:
99
101
  return totals
100
102
  scan = find_untracked(sync_dir, fresh_states)
103
+
104
+ # When --add-all is combined with --include, the bulk-add should
105
+ # honor the same scope the rest of the sync uses — pull, push,
106
+ # tracked-item dispatch, remote-deleted detection all already
107
+ # respect ``patterns``. ``find_untracked`` doesn't see the patterns,
108
+ # so we partition its result here. Files outside the include scope
109
+ # are surfaced separately so the user knows they were skipped on
110
+ # purpose, not silently dropped.
111
+ out_of_include_scope: list[str] = []
112
+ if add_all_files and patterns and scan.untracked:
113
+ in_scope, out_of_include_scope = _partition_by_include(scan.untracked, patterns)
114
+ scan.untracked = in_scope
115
+
101
116
  totals["untracked"] = list(scan.untracked)
102
117
  totals["excluded_count"] = len(scan.excluded)
103
118
  totals["symlinks"] = [{"path": p, "target": t} for p, t in scan.symlinks]
119
+ totals["skipped_by_include"] = list(out_of_include_scope)
104
120
  for state, _tid in fresh_states:
105
121
  for kid, meta in state.conflicts.items():
106
122
  entry = state.get(kid)
@@ -115,16 +131,22 @@ def run_sync_pass(
115
131
  "remote_updated_at": meta.get("remote_updated_at"),
116
132
  }
117
133
  )
118
- _emit_scan(scan, sync_dir, add_all_files, verbose=verbose)
134
+ _emit_scan(scan, sync_dir, add_all_files, dry_run=dry_run, verbose=verbose)
135
+
136
+ if out_of_include_scope:
137
+ cap = None if verbose else _LIST_PREVIEW
138
+ click.echo(f"\nSkipped {len(out_of_include_scope)} untracked file(s) outside --include scope:")
139
+ _print_capped(out_of_include_scope, cap)
119
140
 
120
141
  if add_all_files and scan.untracked and not dry_run:
121
- # All targets share the same sync_dir; bulk-add against the
122
- # first state's root. Multi-root dirs are an edge case we
123
- # don't optimize for here.
124
- first_state, root_id = fresh_states[0]
142
+ # Bulk-add into the resolved target. The CLI rejects --add-all
143
+ # in multi-topic directories without an explicit --topic, so by
144
+ # the time we get here ``targets`` has exactly one entry.
145
+ target_tid = targets[0][1]
146
+ target_state = next(s for s, tid in fresh_states if tid == target_tid)
125
147
  counts = add_all(
126
- first_state,
127
- root_id,
148
+ target_state,
149
+ target_tid,
128
150
  sync_dir,
129
151
  scan.untracked,
130
152
  allow_subtopics=allow_subtopics,
@@ -141,14 +163,29 @@ def run_sync_pass(
141
163
  return totals
142
164
 
143
165
 
144
- def _emit_scan(scan, sync_dir, add_all_files, *, verbose=False):
166
+ def _emit_scan(scan, sync_dir, add_all_files, *, dry_run=False, verbose=False):
145
167
  if not scan.untracked and not scan.symlinks and not (verbose and scan.excluded):
146
168
  return
147
169
  cap = None if verbose else _LIST_PREVIEW
148
170
  if scan.untracked:
149
- click.echo(f"\n{len(scan.untracked)} untracked file(s) in {sync_dir}/:")
150
- _print_capped(scan.untracked, cap)
151
- if not add_all_files:
171
+ # Output is shaped by the (dry_run, add_all_files) combination so
172
+ # the user always knows whether the listed files would actually
173
+ # be uploaded:
174
+ # dry-run + --add-all → preview header ("Would add ...")
175
+ # dry-run, no --add-all → preview header ("would not add")
176
+ # live + --add-all → silent here (the real add path prints
177
+ # its own "Added N file(s)" summary)
178
+ # live, no --add-all → the existing "Choose how to proceed"
179
+ # prompt with the add/skip recipes.
180
+ if dry_run and add_all_files:
181
+ click.echo(f"\nWould add {len(scan.untracked)} file(s):")
182
+ _print_capped(scan.untracked, cap)
183
+ elif dry_run:
184
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) (would not add — re-run with --add-all):")
185
+ _print_capped(scan.untracked, cap)
186
+ elif not add_all_files:
187
+ click.echo(f"\n{len(scan.untracked)} untracked file(s) in {sync_dir}/:")
188
+ _print_capped(scan.untracked, cap)
152
189
  click.echo(" Choose how to proceed:")
153
190
  click.echo(" Upload all: obris sync --add-all")
154
191
  click.echo(" Upload some: obris sync add <file>...")
@@ -161,6 +198,29 @@ def _emit_scan(scan, sync_dir, add_all_files, *, verbose=False):
161
198
  _print_capped(scan.excluded, cap)
162
199
 
163
200
 
201
+ def _partition_by_include(rels: list[str], patterns: list[str]) -> tuple[list[str], list[str]]:
202
+ """Split untracked rel-paths by whether their parent dir is in --include scope.
203
+
204
+ A file's parent directory becomes (or maps to) a subtopic name path
205
+ when ``--add-all`` runs; we test the same name path against the
206
+ patterns the engine already evaluates against the remote subtree.
207
+ Root-level files (parent is ``"."``) test against an empty segment
208
+ list — patterns like ``Projects/**`` won't match, which mirrors
209
+ the engine's own behaviour where the root topic isn't in
210
+ ``matched_ids`` when patterns are set.
211
+ """
212
+ in_scope: list[str] = []
213
+ out_of_scope: list[str] = []
214
+ for rel in rels:
215
+ rel_dir = str(PurePosixPath(rel).parent)
216
+ segments = [] if rel_dir == "." else [s for s in rel_dir.split("/") if s]
217
+ if any_match(segments, patterns):
218
+ in_scope.append(rel)
219
+ else:
220
+ out_of_scope.append(rel)
221
+ return in_scope, out_of_scope
222
+
223
+
164
224
  def _print_capped(items, cap):
165
225
  """Print at most ``cap`` items, then a "... N more" line. None = no cap."""
166
226
  shown = items if cap is None else items[:cap]
@@ -168,84 +228,3 @@ def _print_capped(items, cap):
168
228
  click.echo(f" {line}")
169
229
  if cap is not None and len(items) > cap:
170
230
  click.echo(f" ... ({len(items) - cap} more)")
171
-
172
-
173
- def preview_first_sync(sync_dir: Path, *, add_all_files: bool, allow_subtopics: bool) -> dict:
174
- """Local-only dry-run preview for a fresh directory.
175
-
176
- Called when ``resolve_targets`` returned ``[]`` (dry-run + no
177
- state + no ``--topic``). Side-effect free: walks the dir using a
178
- synthetic empty ``SyncState`` so ``find_untracked`` reuses the
179
- same exclusion + symlink logic the live path uses, then prints
180
- what create_topic / _ensure_subtopic_path / add_all would do.
181
-
182
- Ignored files are surfaced unconditionally — the user explicitly
183
- asked for the full picture before committing to a real sync.
184
-
185
- Returns the same totals dict shape as ``run_sync_pass`` so the
186
- JSON output stays consistent.
187
- """
188
- sync_dir = Path(sync_dir).resolve()
189
- topic_name = sync_dir.name
190
- click.echo(" (dry run — no changes will be made)")
191
- click.echo(f' Would create topic "{topic_name}" at {sync_dir}/')
192
-
193
- synthetic = SyncState("__preview__", str(sync_dir))
194
- scan = find_untracked(sync_dir, [(synthetic, "__preview__")])
195
-
196
- # Subtopic projection: every directory segment under sync_dir that
197
- # contains an untracked file would become a subtopic level. Mirrors
198
- # the cache walk in ``scanner._ensure_subtopic_path``.
199
- subdirs: set[str] = set()
200
- skipped_subdir = 0
201
- if allow_subtopics:
202
- for rel in scan.untracked:
203
- rel_dir = str(PurePosixPath(rel).parent)
204
- if rel_dir == ".":
205
- continue
206
- walked = ""
207
- for seg in rel_dir.split("/"):
208
- walked = f"{walked}/{seg}" if walked else seg
209
- subdirs.add(walked)
210
- else:
211
- skipped_subdir = sum(1 for rel in scan.untracked if "/" in rel)
212
-
213
- if subdirs:
214
- click.echo(f"\nWould create {len(subdirs)} subtopic(s):")
215
- for sd in sorted(subdirs):
216
- click.echo(f" {sd}/")
217
-
218
- if scan.untracked:
219
- if not add_all_files:
220
- click.echo(f"\n{len(scan.untracked)} untracked file(s) (would NOT add — re-run with --add-all):")
221
- _print_capped(scan.untracked, None)
222
- elif not allow_subtopics:
223
- top_level = [r for r in scan.untracked if "/" not in r]
224
- click.echo(f"\nWould add {len(top_level)} top-level file(s):")
225
- _print_capped(top_level, None)
226
- if skipped_subdir:
227
- click.echo(f"\nWould skip {skipped_subdir} file(s) in subdirs (--no-subtopics):")
228
- _print_capped([r for r in scan.untracked if "/" in r], None)
229
- else:
230
- click.echo(f"\nWould add {len(scan.untracked)} file(s):")
231
- _print_capped(scan.untracked, None)
232
-
233
- if scan.excluded:
234
- click.echo(f"\n{len(scan.excluded)} file(s) matched ignore rules:")
235
- _print_capped(scan.excluded, None)
236
-
237
- if scan.symlinks:
238
- click.echo(f"\n{len(scan.symlinks)} symlink(s) skipped:")
239
- _print_capped([f"{p} -> {t}" for p, t in scan.symlinks], None)
240
-
241
- return {
242
- "pulled": 0,
243
- "pushed": 0,
244
- "conflicts": 0,
245
- "errors": 0,
246
- "untracked": list(scan.untracked),
247
- "excluded_count": len(scan.excluded),
248
- "symlinks": [{"path": p, "target": t} for p, t in scan.symlinks],
249
- "conflicts_pending": [],
250
- "missing_local": [],
251
- }
@@ -1,8 +1,14 @@
1
1
  """Sync state persistence and manipulation.
2
2
 
3
- State is stored under ~/.obris/sync/, one file per (root topic, local path).
4
- State is keyed by knowledge_id (the remote item's primary key), not by
5
- filename. Renames on either side are metadata updates, not create+delete.
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
- SYNC_DIR = CONFIG_DIR / "sync"
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
- def _state_path(topic_id, local_path):
32
- return SYNC_DIR / f"{_state_key(topic_id, local_path)}.json"
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
- Returns list of (SyncState, topic_id) tuples.
96
+ Walks ``<local_path>/.obris/*.json``. Returns ``[]`` when the
97
+ state dir doesn't exist yet (fresh, never-synced directory).
78
98
  """
79
- resolved = str(Path(local_path).resolve())
80
- if not SYNC_DIR.exists():
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 SYNC_DIR.glob("*.json"):
104
+ for f in sorted(state_dir.glob("*.json")):
84
105
  data = _read(f)
85
- if data and data.get("local_path") == resolved:
86
- topic_id = data.get("topic_id")
87
- results.append((cls(topic_id, local_path, data), topic_id))
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
- SYNC_DIR.mkdir(parents=True, exist_ok=True)
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 = {
@@ -215,7 +237,7 @@ class SyncState:
215
237
  def mark_unlinked(self, knowledge_id):
216
238
  """Record that ``knowledge_id`` should not be auto-re-pulled.
217
239
 
218
- Used by ``obris sync untrack`` so subsequent syncs don't yank
240
+ Used by ``obris sync unlink`` so subsequent syncs don't yank
219
241
  the remote copy back down. Cleared automatically by ``track``.
220
242
  """
221
243
  if knowledge_id and knowledge_id not in self._unlinked_ids:
@@ -141,7 +141,7 @@ wheels = [
141
141
 
142
142
  [[package]]
143
143
  name = "obris-cli"
144
- version = "0.6.2"
144
+ version = "0.7.3"
145
145
  source = { editable = "." }
146
146
  dependencies = [
147
147
  { name = "click" },
@@ -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