Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions mise-tasks/logseq/entity/proxy/page/lib/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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)
Expand All @@ -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='/')
Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions mise-tasks/logseq/entity/proxy/page/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is the purpose of task-files explained?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose is specified by the Mise Task companion-file contract. task-files:: maps each source repository-relative implementation file to a destination graph-relative path under mise-tasks/, including the entrypoint and runtime helpers. That contract was added by PR #186 companion task contract.

--AI(gpt-6-unspecified Proxy task import review)

f'source-link:: https://github.com/owner/{name}/blob/main/mise-tasks/example\n'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where did source-link:: frontmatter enter into the system?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source-link:: task property was introduced by 1ade5324 define Mise Task entities and references. It is specified in the Mise Task entity Frontmatter section: the executable file or TOML definition, using a review branch/commit during review and the default branch for the maintained implementation.

--AI(gpt-6-unspecified Proxy task import review)

'- 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()
6 changes: 6 additions & 0 deletions pages/Logseq___Entity___Proxy___Page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>` 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/<page>.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.
Expand Down
9 changes: 9 additions & 0 deletions pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this where the idea of the "manifest" entered into the system?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, your PR #186 pointer identifies the earlier documentation introduction: 96b332c7 define proxy companion task contract describes the manifest in the Proxy Page entity and sync task reference. PR #188 supplies the task-import implementation. PR #199 then fixes source worktree identity. The illustrated manifest explanation now lists its sections and explains ownership and onward imports.

--AI(gpt-6-unspecified Proxy task import review)

- 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.
Expand Down