diff --git a/mise-tasks/logseq/entity/proxy/page/lib/core.py b/mise-tasks/logseq/entity/proxy/page/lib/core.py index 534410d93..e78feec77 100644 --- a/mise-tasks/logseq/entity/proxy/page/lib/core.py +++ b/mise-tasks/logseq/entity/proxy/page/lib/core.py @@ -173,7 +173,7 @@ def resolve_page(root: Path, page: str) -> Path: def graph_url(root: Path, page: str) -> str: - return f'logseq://graph/{quote(root.name, safe="")}?page={quote(page, safe="")}' + return f'logseq://graph/{quote(graph_name(root), safe="")}?page={quote(page, safe="")}' def proxy_identity(url: str) -> tuple[str, str]: @@ -191,6 +191,19 @@ def run_local(*args: str, cwd: Path | None = None) -> str: return '' +def primary_checkout(root: Path) -> Path | None: + worktrees = run_local('git', 'worktree', 'list', '--porcelain', cwd=root) + first = worktrees.splitlines()[0] if worktrees else '' + return Path(first.removeprefix('worktree ')) if first.startswith('worktree ') else None + + +def graph_name(root: Path) -> str: + """Use the primary checkout's graph name while reading a linked worktree.""" + repo = run_local('git', 'rev-parse', '--show-toplevel', cwd=root) + primary = primary_checkout(root) if repo else None + return (primary / root.relative_to(Path(repo))).name if primary else root.name + + def infer_codeforge(root: Path, source: Path) -> str | None: repo = run_local('git', 'rev-parse', '--show-toplevel', cwd=root) remote = run_local('git', 'remote', 'get-url', 'origin', cwd=root) @@ -208,6 +221,9 @@ def infer_codeforge(root: Path, source: Path) -> str | None: netloc=parsed_remote.netloc.rsplit('@', 1)[-1], query='', fragment='' ).geturl().removesuffix('.git').rstrip('/') branch = run_local('git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD', cwd=root).removeprefix('origin/') or 'main' + primary = primary_checkout(root) + if primary is not None and Path(repo) != primary: + branch = run_local('git', 'symbolic-ref', '--short', 'HEAD', cwd=root) or run_local('git', 'rev-parse', 'HEAD', cwd=root) or branch relative = source.relative_to(Path(repo)) marker = '/-/blob/' if 'gitlab' in urlparse(remote).netloc else '/blob/' return remote + marker + quote(branch, safe='') + '/' + quote(relative.as_posix(), safe='/') @@ -323,7 +339,7 @@ def add_page(plan: Plan, page: str) -> None: props, _ = parse_properties(old_text) if 'logseq-proxy-url' not in props: raise SyncError(f'Page collision: {target} is not a proxy') - if proxy_identity(props['logseq-proxy-url']) != (plan.source_root.name, page): + if proxy_identity(props['logseq-proxy-url']) != (graph_name(plan.source_root), page): raise SyncError(f'Proxy source conflict: {target}') if props.get('logseq-proxy-codeforge-url'): validate_codeforge(props['logseq-proxy-codeforge-url'], plan.source_root, source) diff --git a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py index 629ab6ec0..e68dfdbfb 100644 --- a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py +++ b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py @@ -291,5 +291,112 @@ def test_manifest_records_repository_relative_graph(self): record = json.loads(manifest)['imports']['github.com/owner/repo::sub/source::Example'] self.assertEqual(record['source_graph'], 'sub/source') +class WorktreeSyncTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name).resolve() + + def tearDown(self): + self.temp.cleanup() + + def git(self, root, *args): + return subprocess.check_output(['git', '-C', str(root), *args], text=True, stderr=subprocess.DEVNULL).strip() + + def repo(self, name, graph_path): + repo = self.root / name + repo.mkdir() + self.git(repo, 'init', '-q', '-b', 'main') + self.git(repo, 'config', 'user.name', 'Test') + self.git(repo, 'config', 'user.email', 'test@example.test') + self.git(repo, 'remote', 'add', 'origin', f'git@github.com:owner/{name}.git') + graph = repo / graph_path + (graph / 'pages').mkdir(parents=True) + (graph / 'logseq').mkdir() + (graph / 'logseq/config.edn').write_text('{}') + (graph / 'pages/.keep').touch() + return repo, graph + + def commit(self, repo): + self.git(repo, 'add', '.') + self.git(repo, 'commit', '-qm', 'fixture') + + def sync(self, source, destination): + import companions + plan = build_page_plan(source, destination, 'Example') + companions.extend_plan(plan) + apply_plan(plan) + return plan + + def test_source_and_destination_worktrees_and_relocated_clones(self): + for scope in ('.', 'sub/source'): + with self.subTest(scope=scope): + name = 'source-root' if scope == '.' else 'source-nested' + repo, source = self.repo(name, scope) + (repo / 'mise-tasks').mkdir() + implementation = repo / 'mise-tasks/example' + implementation.write_text('#!/bin/sh\necho main\n') + implementation.chmod(0o755) + (source / 'pages/Example.md').write_text( + 'task-owner:: [[Owner]]\ntask-config-root:: .\ntask-name:: example\n' + 'task-entrypoint:: mise-tasks/example\n' + 'task-files:: {"mise-tasks/example":"mise-tasks/example"}\n' + f'source-link:: https://github.com/owner/{name}/blob/main/mise-tasks/example\n' + '- main\n') + self.commit(repo) + dest_repo, destination = self.repo('destination-' + name, 'garden') + self.commit(dest_repo) + source_worktree = self.root / ('189-' + name) + destination_worktree = self.root / ('destination-worktree-' + name) + self.git(repo, 'worktree', 'add', '-qb', 'codex/189-proxy', str(source_worktree)) + self.git(dest_repo, 'worktree', 'add', '-qb', 'codex/dependent', str(destination_worktree)) + worktree_graph = source_worktree / scope + target_graph = destination_worktree / 'garden' + self.sync(source, target_graph) + expected_url = graph_url(source, 'Example') + (worktree_graph / 'pages/Example.md').write_text( + (worktree_graph / 'pages/Example.md').read_text().replace('- main', '- branch')) + (source_worktree / 'mise-tasks/example').write_text('#!/bin/sh\necho branch\n') + plan = self.sync(worktree_graph, target_graph) + props, body = parse_properties((target_graph / 'pages/Example.md').read_text()) + self.assertEqual(props['logseq-proxy-url'], expected_url) + self.assertIn('/blob/codex%2F189-proxy/', props['logseq-proxy-codeforge-url']) + self.assertEqual(body, '- branch\n') + self.assertIn('echo branch', (target_graph / 'mise-tasks/example').read_text()) + manifest = plan.writes['.logseq-proxy/manifest.json'] + self.assertNotIn(str(self.root).encode(), manifest) + repeat = self.sync(worktree_graph, target_graph) + self.assertTrue(all(data == repeat.expected[path] for path, data in repeat.writes.items())) + # A normal re-sync after merge uses the registered graph identity again. + manifest = self.sync(source, target_graph).writes['.logseq-proxy/manifest.json'] + self.commit(destination_worktree) + relocation = self.root / ('relocated-' + name) + relocation.mkdir() + relocated_repo = relocation / name + self.git(relocation, 'clone', '-q', str(repo), str(relocated_repo)) + self.git(relocated_repo, 'remote', 'set-url', 'origin', f'git@github.com:owner/{name}.git') + relocated_destination = relocation / 'destination/garden' + self.git(relocation, 'clone', '-q', '-b', 'codex/dependent', str(dest_repo), str(relocated_destination.parent)) + relocated = self.sync(relocated_repo / scope, relocated_destination) + self.assertEqual(relocated.writes['.logseq-proxy/manifest.json'], manifest) + # Same graph folder name in another repository still cannot claim the page. + self.git(relocated_repo, 'remote', 'set-url', 'origin', 'git@github.com:other/repo.git') + before = (relocated_destination / 'pages/Example.md').read_bytes() + with self.assertRaisesRegex(SyncError, 'ownership conflict'): + self.sync(relocated_repo / scope, relocated_destination) + self.assertEqual((relocated_destination / 'pages/Example.md').read_bytes(), before) + + def test_detached_source_worktree_url_names_commit(self): + repo, source = self.repo('detached-source', '.') + (source / 'pages/Example.md').write_text('- main\n') + self.commit(repo) + worktree = self.root / 'detached-worktree' + self.git(repo, 'worktree', 'add', '-q', '--detach', str(worktree)) + _, destination = self.repo('detached-destination', 'garden') + plan = build_page_plan(worktree, destination, 'Example') + props, _ = parse_properties(plan.writes['pages/Example.md'].decode()) + self.assertEqual(props['logseq-proxy-url'], graph_url(source, 'Example')) + self.assertIn('/blob/' + self.git(repo, 'rev-parse', 'HEAD') + '/', props['logseq-proxy-codeforge-url']) + + if __name__ == '__main__': unittest.main() diff --git a/pages/Logseq___Entity___Proxy___Page.md b/pages/Logseq___Entity___Proxy___Page.md index 399dda663..0b2247aa2 100644 --- a/pages/Logseq___Entity___Proxy___Page.md +++ b/pages/Logseq___Entity___Proxy___Page.md @@ -41,6 +41,12 @@ entity-proxy-destination-properties:: public - **With a code forge URL:** the host, owner and repository in [[Logseq/Entity/Proxy/Page/Frontmatter/logseq-proxy-codeforge-url]] form a `ghq` address, and `ghq list --full-path --exact
` gives the local clone. One path is the clone; no path means the repository is not cloned here, and cloning waits for the human; several paths wait for the human to pick one. The part of the URL between the branch and `pages/` is the graph's folder inside the clone, empty when the graph sits at the repository root. - **Without one:** the graph name in [[Logseq/Entity/Proxy/Page/Frontmatter/logseq-proxy-url]] resolves through Logseq desktop's own graph list, `~/.logseq/graphs/`, which holds one file per graph with the graph's path in the file name. The graph is the one whose folder carries that name. This resolves only on a machine where Logseq has the graph. - A graph root holds `pages/` and `logseq/`. + - ## Dependent work across repositories + - `--source` may name a linked source worktree, and `--destination` may name a graph inside a linked destination worktree. Content and companion implementations are read from the selected source checkout. + - The Logseq graph name comes from the primary checkout at the same repository-relative graph path. A graph at the repository root keeps the registered checkout's name; a nested graph keeps its folder name. A linked worktree therefore shares its source graph's logical identity. + - Source ownership combines the repository address, repository-relative graph path, and logical page or implementation path. Branch names and local checkout paths are excluded from ownership, so imports can refresh from a source worktree and later from the merged primary checkout. + - A source worktree's code forge URLs name its branch, or its commit when detached. Push the source branch before opening a dependent destination PR so its source links are followable. After the source PR merges, re-sync from the primary checkout to refresh the links to the default branch. + - Keep the destination's `.logseq-proxy/manifest.json` and imported task files together in its PR. Record the source PR dependency, source and destination branches, and worktree paths in the issue discussion. A relocated clone resolves the same ownership through its repository address and graph path. - ## Syncing an instance - **Source file:** the file the code forge URL names, or else `pages/.md` under the graph root, with `page=` decoded (`%2F` is `/`) and each `/` written as `___`. - **First copy:** the whole source file, with the proxy keys set in its property block. diff --git a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md index 35dd06875..055f4b06f 100644 --- a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md +++ b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md @@ -22,6 +22,15 @@ see-also:: [[Logseq/Entity/Proxy/Page]], [[Logseq/Entity/Definition]] - ~~~sh mise run logseq:entity:proxy:page:sync --source /path/to/logseq-encode-garden --destination /path/to/other-garden --page 'GitP/A/Session/26/09/24-Thu' --follow-embeds --apply ~~~ + - ## Source and destination worktrees + - Select each worktree explicitly while its dependent source changes are under review: + - ~~~sh + python3 /path/to/source-worktree/mise-tasks/logseq/entity/proxy/page/sync --source /path/to/source-worktree --destination /path/to/destination-worktree/garden --page 'Logseq/Entity/Proxy/Page' --apply + ~~~ + - Invoking the source checkout's script imports the current sync implementation along with the entity definition. The destination can then run its imported task for other pages from the same source worktree. + - The source worktree supplies content while the primary checkout supplies the logical graph name. Code forge URLs point to the selected source branch, or to the commit for a detached worktree. The portable manifest keeps the same source ownership across branches and checkouts. + - Commit the manifest with the imported pages and task files. Push the source branch and link the source PR as a dependency of the destination PR. After the source PR merges, re-sync with the primary checkout selected as `--source` to update the source URLs to the default branch. + - Omitting `--source` resolves the registered primary checkout. An unmerged source branch therefore requires an explicit source worktree path on each sync. - ## Inputs and outputs - Reads the source page and the entity definitions named by `logseq-entity::`. Definitions declare companion tasks with `entity-tasks::`; task references declare implementation paths and required task dependencies through [[Logseq/Entity/Mise/Task]]. - Copies pages under their source logical names, adds [[Logseq/Entity/Proxy/Page]] membership, and sets source URL, available code forge URL, and sync date. Source bodies replace destination bodies.