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.
- {obris_cli-0.6.2 → obris_cli-0.7.3}/PKG-INFO +1 -1
- {obris_cli-0.6.2 → obris_cli-0.7.3}/README.md +1 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/pyproject.toml +1 -1
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/sync.py +13 -2
- obris_cli-0.7.3/src/obris/commands/sync_config.py +290 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/run.py +8 -2
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/tracked.py +1 -1
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/exclusions.py +35 -29
- obris_cli-0.7.3/src/obris/sync/preview.py +109 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/runner.py +71 -92
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/state.py +44 -22
- {obris_cli-0.6.2 → obris_cli-0.7.3}/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.3}/.claude/settings.local.json +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/.gitignore +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/ruff.toml +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/client.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/knowledge.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/api/topics.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/auth/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/auth/session.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/cli.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/auth.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/env.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/save.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/sync_conflicts.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/config.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/output.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/routes.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/commands.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/constants.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/filters.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/manifest.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/engine/subtree.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/io.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/mapping.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/models.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/resolver.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/sync/scanner.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/notify.py +0 -0
- {obris_cli-0.6.2 → obris_cli-0.7.3}/src/obris/utils/upload.py +0 -0
|
@@ -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 |
|
|
@@ -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
|
|
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 /
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
@@ -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
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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 = {
|
|
@@ -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
|
|
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:
|
|
@@ -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
|