lens: wire the git ents lsp subcommand onto the local root
commit 8abf4ea
lens: wire the git ents lsp subcommand onto the local root
Add git ents lsp, the editor frontend of the local composition root
(lens.serve). It reuses the exact same LocalRoot wiring git ents serve
uses — the same loose-ref RefStore, odb, null EventSink, and advisory
gate — adding only the ents-lens LSP frontend over stdio, injecting the
user’s own key as the signing identity (roots.web-agnostic parity, the
local half of roots.web-signing, no server-key indirection). It binds no
socket and adds no git transport.
Two fixes were needed to make the server actually serve over real stdio,
each verified end to end by driving the binary with a live LSP client:
git-ents main held std::io::stdout().lock() for the whole command, which
deadlocked lsp-server’s writer thread when it took the same lock. Pass
the Stdout handle instead (per-write locking), behaviorally identical for
every other, single-threaded command.
serve_stdio joined the IO threads while still holding the Connection, so
the writer thread’s channel never closed and the join hung. Drop the
connection before joining.
Also advertise codeActionProvider as a plain boolean rather than empty
options, so clients read it unambiguously as supported.
crates/cli/ents-lens/src/server.rs
@@ -28,7 +28,7 @@
CodeActionRequest, CodeLensRequest, ExecuteCommand, HoverRequest, Request as _, ShowDocument,
};
use lsp_types::{
- CodeActionOptions, CodeActionProviderCapability, CodeLensOptions, DidChangeTextDocumentParams,
+ CodeActionProviderCapability, CodeLensOptions, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams,
ExecuteCommandOptions, HoverProviderCapability, PublishDiagnosticsParams, ServerCapabilities,
ShowDocumentParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
@@ -59,9 +59,7 @@
resolve_provider: Some(false),
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
- code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
- ..CodeActionOptions::default()
- })),
+ code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
execute_command_provider: Some(ExecuteCommandOptions {
commands: vec![
render::CMD_VIEW.to_owned(),
@@ -93,6 +91,11 @@
.map_err(|source| std::io::Error::other(source.to_string()))?;
let mut server = ServerLoop { connection, lens };
server.run()?;
+ // Drop the connection (and with it the writer's channel sender) before
+ // joining: the IO writer thread only terminates once its sender is gone,
+ // so joining while `server` still holds the connection would hang here
+ // forever.
+ drop(server);
io_threads.join()?;
Ok(())
}
crates/cli/git-ents/src/cli.rs
@@ -28,7 +28,7 @@
}
/// Every top-level `git ents` subcommand.
-// @relation(roots.local, roots.worktree-update, roots.single-node-hosted, scope=file)
+// @relation(roots.local, roots.worktree-update, roots.single-node-hosted, lens.serve, scope=file)
#[derive(Facet)]
#[repr(u8)]
pub enum Top {
@@ -144,6 +144,25 @@
#[facet(args::named)]
key: Option<PathBuf>,
},
+ /// Serve the editor lens (`lens.serve`): a Language Server Protocol
+ /// server over stdin/stdout that projects this repository's comments
+ /// (`refs/meta/comments/*`) into whatever buffer an editor has open,
+ /// and composes new ones through the same signed path `git ents
+ /// comment` uses (`lens.parity`).
+ ///
+ /// Speaks LSP over stdio only: it binds no network socket and adds no
+ /// git-serving transport. It reuses the very same local composition
+ /// root `git ents serve` and every other porcelain command use (the
+ /// same loose-ref `RefStore`, odb, null `EventSink`, and advisory
+ /// gate), adding only the LSP frontend and signing with the user's own
+ /// key. Meant to be launched by an editor extension (e.g. `ents-zed`),
+ /// not run interactively.
+ Lsp {
+ /// Key to sign composed comments with; defaults to
+ /// `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
}
/// `git ents members` actions.
crates/cli/git-ents/src/exe.rs
@@ -58,6 +58,14 @@
let root = LocalRoot::discover(".")?;
commands::serve::run(root, port, key, out)
}
+ Top::Lsp { key } => {
+ // The lens speaks LSP over stdin/stdout, so nothing may be
+ // written to `out` (the process's stdout) here — that stream is
+ // the protocol channel. It reuses the exact same local root
+ // `serve` does (`lens.serve`), adding only the LSP frontend.
+ let root = LocalRoot::discover(".")?;
+ commands::lsp::run(root, key)
+ }
}
}
crates/cli/git-ents/src/main.rs
@@ -6,8 +6,13 @@
fn main() -> ExitCode {
let cli: Cli = figue::from_std_args().unwrap();
- let stdout = std::io::stdout();
- let mut out = stdout.lock();
+ // The `Stdout` handle, not a held `StdoutLock`: `git ents lsp`
+ // (`lens.serve`) hands stdout to `lsp-server`, whose writer thread takes
+ // the lock itself, so holding it here for the whole command would
+ // deadlock that thread. Every other command writes through the handle's
+ // own per-write lock, which is behaviorally identical for their
+ // single-threaded output.
+ let mut out = std::io::stdout();
match git_ents::exe::run(cli, &mut out) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
crates/cli/git-ents/tests/cli_help.rs
@@ -45,11 +45,28 @@
"redact",
"hook",
"serve",
+ "lsp",
] {
assert!(help.contains(name), "--help must mention {name:?}:\n{help}");
}
}
+/// `git ents lsp --help` documents the stdio-only, no-socket,
+/// no-git-transport contract `lens.serve` requires, not just a bare flag
+/// list.
+#[test]
+// @relation(lens.serve, scope=function, role=Verifies)
+fn lsp_help_documents_the_stdio_only_contract() {
+ let output = Command::new(common::bin_path())
+ .args(["lsp", "--help"])
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ let text = String::from_utf8(output.stdout).expect("utf8");
+ assert!(text.contains("stdio") || text.contains("stdin"), "{text}");
+ assert!(text.contains("socket"), "{text}");
+}
+
/// `--help` carries this crate's own one-line responsibility, not a
/// generic placeholder.
#[test]
crates/cli/git-ents/src/commands/mod.rs
@@ -13,6 +13,7 @@
pub mod effect;
pub mod inbox;
pub mod issue;
+pub mod lsp;
pub mod members;
pub mod redact;
pub mod review;
crates/cli/git-ents/src/commands/lsp.rs
@@ -1,0 +1,72 @@
+//! `git ents lsp`: reuse [`LocalRoot`]'s existing wiring and add only the
+//! `ents-lens` Language Server Protocol frontend, over stdio (`lens.serve`).
+//!
+//! `lens.serve` requires this command to serve LSP over stdio reusing the
+//! local composition root exactly as `git ents serve` reuses it for the web
+//! UI (`roots.local`), binding no socket and adding no git transport. This
+//! module upholds that: it is handed an already-open [`LocalRoot`] (never
+//! opens its own), resolves the user's own signing key exactly as every
+//! other mutation command does, and hands both to
+//! [`ents_lens::serve_stdio`], which speaks only stdin/stdout.
+//!
+//! The signing identity is injected the same way `serve` injects
+//! `ents-web`'s (`roots.web-agnostic` parity): the lens crate resolves no
+//! key and assumes no editor is attached; this composition root builds an
+//! owned [`ents_lens::Signing`] from the user's own key
+//! (`roots.web-signing`'s local half — no server-key indirection exists
+//! here) and moves it in.
+
+use std::path::PathBuf;
+
+use ents_lens::{Lens, Signing};
+
+use super::{actor, signer};
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+
+/// Run `git ents lsp`: build the lens from `root`'s seams and the user's
+/// resolved signing key, then serve LSP over stdio until the client shuts
+/// it down.
+///
+/// Takes no output writer: the process's stdout is the LSP protocol
+/// channel, so this command must write nothing else to it.
+///
+/// # Errors
+///
+/// Propagates a signing-key resolution failure ([`crate::sign::Signer`]),
+/// or an [`Error::Io`] if the LSP transport fails.
+// @relation(lens.serve, roots.local, scope=function)
+pub fn run(root: LocalRoot, key: Option<PathBuf>) -> Result<()> {
+ let signer = signer(&root, key)?;
+ let identity_actor = actor(&signer);
+ let public_openssh = signer.public_openssh();
+ // The user's own key signs composed comments (`roots.web-signing`'s
+ // local half): no server-key indirection is imported into the local
+ // root, exactly as `serve` keeps it out.
+ let signing = Signing::new(
+ identity_actor,
+ Box::new(move |payload| signer.sign(payload)),
+ public_openssh,
+ );
+
+ let mode = root.mode();
+ let LocalRoot {
+ path,
+ refs,
+ objects,
+ events,
+ executor: _,
+ } = root;
+ let lens = Lens::new(
+ Box::new(refs),
+ objects,
+ Box::new(events),
+ mode,
+ signing,
+ path,
+ );
+ ents_lens::serve_stdio(lens).map_err(|source| Error::Io {
+ path: PathBuf::from("<lsp stdio>"),
+ source,
+ })
+}