obris-cli 0.7.0__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 (50) hide show
  1. {obris_cli-0.7.0 → obris_cli-0.7.3}/PKG-INFO +1 -1
  2. {obris_cli-0.7.0 → obris_cli-0.7.3}/README.md +1 -0
  3. {obris_cli-0.7.0 → obris_cli-0.7.3}/pyproject.toml +1 -1
  4. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync.py +13 -2
  5. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync_config.py +14 -14
  6. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/run.py +1 -1
  7. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/tracked.py +1 -1
  8. obris_cli-0.7.3/src/obris/sync/preview.py +109 -0
  9. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/runner.py +51 -87
  10. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/state.py +1 -1
  11. {obris_cli-0.7.0 → obris_cli-0.7.3}/uv.lock +1 -1
  12. {obris_cli-0.7.0 → obris_cli-0.7.3}/.claude/settings.local.json +0 -0
  13. {obris_cli-0.7.0 → obris_cli-0.7.3}/.gitignore +0 -0
  14. {obris_cli-0.7.0 → obris_cli-0.7.3}/ruff.toml +0 -0
  15. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/__init__.py +0 -0
  16. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/__init__.py +0 -0
  17. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/client.py +0 -0
  18. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/knowledge.py +0 -0
  19. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/topics.py +0 -0
  20. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/assets/icon.png +0 -0
  21. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/auth/__init__.py +0 -0
  22. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/auth/session.py +0 -0
  23. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/cli.py +0 -0
  24. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/__init__.py +0 -0
  25. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/auth.py +0 -0
  26. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/env.py +0 -0
  27. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/knowledge.py +0 -0
  28. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/save.py +0 -0
  29. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync_conflicts.py +0 -0
  30. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/topic.py +0 -0
  31. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/config.py +0 -0
  32. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/output.py +0 -0
  33. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/routes.py +0 -0
  34. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/__init__.py +0 -0
  35. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/commands.py +0 -0
  36. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/constants.py +0 -0
  37. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/__init__.py +0 -0
  38. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/filters.py +0 -0
  39. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/manifest.py +0 -0
  40. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/subtree.py +0 -0
  41. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/exclusions.py +0 -0
  42. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/io.py +0 -0
  43. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/mapping.py +0 -0
  44. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/models.py +0 -0
  45. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/resolver.py +0 -0
  46. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/scanner.py +0 -0
  47. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/__init__.py +0 -0
  48. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/capture.py +0 -0
  49. {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/notify.py +0 -0
  50. {obris_cli-0.7.0 → 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.7.0
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.7.0"
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)
@@ -1,6 +1,6 @@
1
1
  """Per-checkout sync configuration subcommands.
2
2
 
3
- Hosts ``exclude`` / ``include`` / ``untrack`` — commands that modify
3
+ Hosts ``exclude`` / ``include`` / ``unlink`` — commands that modify
4
4
  state-file metadata for a synced directory. Kept out of
5
5
  ``commands.sync`` so the sync group definition + invocation logic
6
6
  stays under the 300-line cap and these subcommands stay together
@@ -88,26 +88,26 @@ def register(sync):
88
88
  results = _apply_pattern_action(states, patterns, action="include", sync_dir=sync_dir)
89
89
  _emit_outcomes(sync_dir, results, action="include")
90
90
 
91
- @sync.command("untrack")
91
+ @sync.command("unlink")
92
92
  @click.argument("targets", nargs=-1, required=True)
93
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.
94
+ def sync_unlink(targets, path):
95
+ """Break the local-to-remote sync link without deleting either side.
96
96
 
97
- Each TARGET is a knowledge ID or a tracked filename (basename).
97
+ Each TARGET is a knowledge ID or a linked filename (basename).
98
98
  Removes the sync link; both the local file and the remote item stay
99
99
  in place. Subsequent 'obris sync' calls will not re-pull these items.
100
100
 
101
101
  \b
102
- obris sync untrack abc123
103
- obris sync untrack notes.md draft.md
102
+ obris sync unlink abc123
103
+ obris sync unlink notes.md draft.md
104
104
 
105
105
  Re-link later with 'obris sync link <file> -i <id>' or 'obris sync add'.
106
106
  Permanently delete the remote with 'obris knowledge delete <id>'.
107
107
  """
108
108
  sync_dir, states = _states_for_current_dir(path)
109
109
 
110
- untracked = []
110
+ unlinked = []
111
111
  not_found = []
112
112
  for target in targets:
113
113
  matches = _resolve_target(target, states)
@@ -115,7 +115,7 @@ def register(sync):
115
115
  not_found.append(target)
116
116
  continue
117
117
  if len(matches) > 1:
118
- click.echo(f"Ambiguous: '{target}' matches multiple tracked items:")
118
+ click.echo(f"Ambiguous: '{target}' matches multiple linked items:")
119
119
  for state, kid in matches:
120
120
  entry = state.get(kid)
121
121
  click.echo(f" {kid} ({entry.filename})")
@@ -126,15 +126,15 @@ def register(sync):
126
126
  state.untrack(kid)
127
127
  state.mark_unlinked(kid)
128
128
  state.save()
129
- untracked.append((kid, entry.filename))
129
+ unlinked.append((kid, entry.filename))
130
130
 
131
- if untracked:
132
- click.echo(f"Untracked {len(untracked)} item(s):")
133
- for kid, name in untracked:
131
+ if unlinked:
132
+ click.echo(f"Unlinked {len(unlinked)} item(s):")
133
+ for kid, name in unlinked:
134
134
  click.echo(f" {name} ({kid})")
135
135
  click.echo(f" Local files and remote items unchanged in {sync_dir}/.")
136
136
  if not_found:
137
- click.echo(f"Not tracked: {', '.join(not_found)}", err=True)
137
+ click.echo(f"Not linked: {', '.join(not_found)}", err=True)
138
138
  raise SystemExit(1)
139
139
 
140
140
 
@@ -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
@@ -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
 
@@ -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)
@@ -117,14 +133,20 @@ def run_sync_pass(
117
133
  )
118
134
  _emit_scan(scan, sync_dir, add_all_files, dry_run=dry_run, verbose=verbose)
119
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)
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,
@@ -176,6 +198,29 @@ def _emit_scan(scan, sync_dir, add_all_files, *, dry_run=False, verbose=False):
176
198
  _print_capped(scan.excluded, cap)
177
199
 
178
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
+
179
224
  def _print_capped(items, cap):
180
225
  """Print at most ``cap`` items, then a "... N more" line. None = no cap."""
181
226
  shown = items if cap is None else items[:cap]
@@ -183,84 +228,3 @@ def _print_capped(items, cap):
183
228
  click.echo(f" {line}")
184
229
  if cap is not None and len(items) > cap:
185
230
  click.echo(f" ... ({len(items) - cap} more)")
186
-
187
-
188
- def preview_first_sync(sync_dir: Path, *, add_all_files: bool, allow_subtopics: bool) -> dict:
189
- """Local-only dry-run preview for a fresh directory.
190
-
191
- Called when ``resolve_targets`` returned ``[]`` (dry-run + no
192
- state + no ``--topic``). Side-effect free: walks the dir using a
193
- synthetic empty ``SyncState`` so ``find_untracked`` reuses the
194
- same exclusion + symlink logic the live path uses, then prints
195
- what create_topic / _ensure_subtopic_path / add_all would do.
196
-
197
- Ignored files are surfaced unconditionally — the user explicitly
198
- asked for the full picture before committing to a real sync.
199
-
200
- Returns the same totals dict shape as ``run_sync_pass`` so the
201
- JSON output stays consistent.
202
- """
203
- sync_dir = Path(sync_dir).resolve()
204
- topic_name = sync_dir.name
205
- click.echo(" (dry run — no changes will be made)")
206
- click.echo(f' Would create topic "{topic_name}" at {sync_dir}/')
207
-
208
- synthetic = SyncState("__preview__", str(sync_dir))
209
- scan = find_untracked(sync_dir, [(synthetic, "__preview__")])
210
-
211
- # Subtopic projection: every directory segment under sync_dir that
212
- # contains an untracked file would become a subtopic level. Mirrors
213
- # the cache walk in ``scanner._ensure_subtopic_path``.
214
- subdirs: set[str] = set()
215
- skipped_subdir = 0
216
- if allow_subtopics:
217
- for rel in scan.untracked:
218
- rel_dir = str(PurePosixPath(rel).parent)
219
- if rel_dir == ".":
220
- continue
221
- walked = ""
222
- for seg in rel_dir.split("/"):
223
- walked = f"{walked}/{seg}" if walked else seg
224
- subdirs.add(walked)
225
- else:
226
- skipped_subdir = sum(1 for rel in scan.untracked if "/" in rel)
227
-
228
- if subdirs:
229
- click.echo(f"\nWould create {len(subdirs)} subtopic(s):")
230
- for sd in sorted(subdirs):
231
- click.echo(f" {sd}/")
232
-
233
- if scan.untracked:
234
- if not add_all_files:
235
- click.echo(f"\n{len(scan.untracked)} untracked file(s) (would NOT add — re-run with --add-all):")
236
- _print_capped(scan.untracked, None)
237
- elif not allow_subtopics:
238
- top_level = [r for r in scan.untracked if "/" not in r]
239
- click.echo(f"\nWould add {len(top_level)} top-level file(s):")
240
- _print_capped(top_level, None)
241
- if skipped_subdir:
242
- click.echo(f"\nWould skip {skipped_subdir} file(s) in subdirs (--no-subtopics):")
243
- _print_capped([r for r in scan.untracked if "/" in r], None)
244
- else:
245
- click.echo(f"\nWould add {len(scan.untracked)} file(s):")
246
- _print_capped(scan.untracked, None)
247
-
248
- if scan.excluded:
249
- click.echo(f"\n{len(scan.excluded)} file(s) matched ignore rules:")
250
- _print_capped(scan.excluded, None)
251
-
252
- if scan.symlinks:
253
- click.echo(f"\n{len(scan.symlinks)} symlink(s) skipped:")
254
- _print_capped([f"{p} -> {t}" for p, t in scan.symlinks], None)
255
-
256
- return {
257
- "pulled": 0,
258
- "pushed": 0,
259
- "conflicts": 0,
260
- "errors": 0,
261
- "untracked": list(scan.untracked),
262
- "excluded_count": len(scan.excluded),
263
- "symlinks": [{"path": p, "target": t} for p, t in scan.symlinks],
264
- "conflicts_pending": [],
265
- "missing_local": [],
266
- }
@@ -237,7 +237,7 @@ class SyncState:
237
237
  def mark_unlinked(self, knowledge_id):
238
238
  """Record that ``knowledge_id`` should not be auto-re-pulled.
239
239
 
240
- Used by ``obris sync untrack`` so subsequent syncs don't yank
240
+ Used by ``obris sync unlink`` so subsequent syncs don't yank
241
241
  the remote copy back down. Cleared automatically by ``track``.
242
242
  """
243
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.7.0"
144
+ version = "0.7.3"
145
145
  source = { editable = "." }
146
146
  dependencies = [
147
147
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes