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.
- {obris_cli-0.7.0 → obris_cli-0.7.3}/PKG-INFO +1 -1
- {obris_cli-0.7.0 → obris_cli-0.7.3}/README.md +1 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/pyproject.toml +1 -1
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync.py +13 -2
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync_config.py +14 -14
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/run.py +1 -1
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/tracked.py +1 -1
- obris_cli-0.7.3/src/obris/sync/preview.py +109 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/runner.py +51 -87
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/state.py +1 -1
- {obris_cli-0.7.0 → obris_cli-0.7.3}/uv.lock +1 -1
- {obris_cli-0.7.0 → obris_cli-0.7.3}/.claude/settings.local.json +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/.gitignore +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/ruff.toml +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/client.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/knowledge.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/api/topics.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/assets/icon.png +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/auth/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/auth/session.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/cli.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/auth.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/env.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/knowledge.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/save.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/sync_conflicts.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/commands/topic.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/config.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/output.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/routes.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/commands.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/constants.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/filters.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/manifest.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/engine/subtree.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/exclusions.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/io.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/mapping.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/models.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/resolver.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/sync/scanner.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/__init__.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/capture.py +0 -0
- {obris_cli-0.7.0 → obris_cli-0.7.3}/src/obris/utils/notify.py +0 -0
- {obris_cli-0.7.0 → 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)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Per-checkout sync configuration subcommands.
|
|
2
2
|
|
|
3
|
-
Hosts ``exclude`` / ``include`` / ``
|
|
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("
|
|
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
|
|
95
|
-
"""
|
|
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
|
|
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
|
|
103
|
-
obris sync
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
+
unlinked.append((kid, entry.filename))
|
|
130
130
|
|
|
131
|
-
if
|
|
132
|
-
click.echo(f"
|
|
133
|
-
for kid, name in
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
@@ -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
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|