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/