From 85fdb09abbf48f14cf78aa91c45579e9d02393af Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Fri, 28 Aug 2026 22:48:52 -0400 Subject: [PATCH 1/3] fix(worktree): export SubmoduleSeeding and seal RemovedWorktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CreatedWorktree::submodule_seeding` is a public field, but its type lived in the private `service` module and was never re-exported — so an embedder could read the field and had no way to name what it got back. A crate-internal test cannot catch that, since `service` is visible in-crate, so the regression guard is a doctest: it compiles as its own crate and fails exactly when an outcome struct exposes a type the public API does not export. `RemovedWorktree` gains `#[non_exhaustive]`, matching `CreatedWorktree` and `SubmoduleSeeding`. Both types are unreleased — neither is in v1.5.0 and the v1.6.0 release PR is still open — so sealing it now costs no consumer a break, whereas leaving it open makes every field added later a breaking change. The module doc also stops linking to the private `service` and `rows` modules, which rustdoc could not resolve. Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo --- src/worktree/mod.rs | 26 ++++++++++++++++++++++---- src/worktree/service.rs | 1 + 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/worktree/mod.rs b/src/worktree/mod.rs index 1fe80a6..9f7ae50 100644 --- a/src/worktree/mod.rs +++ b/src/worktree/mod.rs @@ -1,7 +1,7 @@ //! The worktree operation layer (issue #95). //! -//! [`service`] is the public, stateless surface: a [`Workspace`] discovered -//! from a directory can enumerate, create, and remove worktrees and read their +//! [`Workspace`] is the public, stateless surface: discovered from a +//! directory, it enumerates, creates, and removes worktrees and reads their //! `wt.*` metadata with **no prompting, no terminal, and no //! [`Cx`](crate::cx::Cx)** — outcomes and warnings are returned as data and the //! caller decides how (or whether) to present them. The `wt` CLI and TUI are @@ -9,9 +9,27 @@ //! directly and must resolve worktree paths through it rather than //! reimplementing the `.wt.toml` layout rules. //! -//! [`rows`] is the crate-internal row assembly on top: enriched listing rows, +//! `rows` is the crate-internal row assembly on top: enriched listing rows, //! worktree-less branch rows (issue #47), sorting, and the remove/prune guard //! evaluation shared by the CLI and TUI. +//! +//! Every type reachable through the public API is re-exported here. A doctest +//! compiles as its own crate, so this one fails exactly when an embedder could +//! not name what [`CreatedWorktree`] and [`RemovedWorktree`] hand back — which +//! a crate-internal test cannot catch, since `service` is visible in-crate: +//! +//! ``` +//! use wt::worktree::{ +//! CreatedWorktree, HookOutcome, RemovedWorktree, SubmoduleSeeding, SubmodulesOutcome, +//! }; +//! +//! fn inspect(created: &CreatedWorktree, removed: &RemovedWorktree) { +//! let _: &SubmoduleSeeding = &created.submodule_seeding; +//! let _: &SubmodulesOutcome = &created.submodules; +//! let _: &HookOutcome = &created.post_create; +//! let _: &HookOutcome = &removed.pre_remove; +//! } +//! ``` pub(crate) mod materialize; pub(crate) mod rows; @@ -31,7 +49,7 @@ pub(crate) use rows::{ pub(crate) use rows::{enumerate_rows, sort_worktrees_base_first}; pub use service::{ CreateOptions, CreatedWorktree, HookOutcome, MetaUpdate, RemoveOptions, RemovedWorktree, - RepoLock, SubmodulesOutcome, Workspace, + RepoLock, SubmoduleSeeding, SubmodulesOutcome, Workspace, }; #[cfg(feature = "cli")] pub(crate) use service::{ diff --git a/src/worktree/service.rs b/src/worktree/service.rs index 9cce5ba..bfb8e52 100644 --- a/src/worktree/service.rs +++ b/src/worktree/service.rs @@ -469,6 +469,7 @@ pub struct CreatedWorktree { /// The outcome of [`Workspace::remove`]. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct RemovedWorktree { /// Whether the local branch was deleted along with the worktree. pub branch_deleted: bool, From df56a6ae1c9e57b9e84a2e9e15332a9afc58dacf Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Fri, 28 Aug 2026 22:49:09 -0400 Subject: [PATCH 2/3] docs(lib): document the embedding surface in rustdoc An embedder reading the signatures cannot see the rules that matter most: which module owns path resolution and why reimplementing it is a bug, what each `wt..*` key means and which changes to the namespace are safe without a schema bump, and how the crate splits into an application surface and an engine. Documents that on the items themselves, so the README is not the only source. Also clears the remaining rustdoc warnings, leaving `cargo doc --no-deps` clean: two redundant explicit link targets and a link to a private field. Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo --- src/config/wtconfig.rs | 29 +++++++++++++++++++++++++++++ src/git/mod.rs | 2 +- src/lib.rs | 33 +++++++++++++++++++++++++++++++++ src/template.rs | 9 +++++++++ src/tui/app.rs | 2 +- 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/config/wtconfig.rs b/src/config/wtconfig.rs index bb5561b..a5dfc8e 100644 --- a/src/config/wtconfig.rs +++ b/src/config/wtconfig.rs @@ -4,6 +4,35 @@ //! Metadata is keyed by branch (`[wt ""]`), so it is shared across the //! repo yet unambiguous per worktree. Reads use `gix`; writes use `git config` //! (a sanctioned §4 fallback — `gix`'s config file-writing is not yet stable). +//! +//! # The metadata contract +//! +//! Every key lives under `wt..*` in the repository's git config, and +//! all of them are optional: +//! +//! | Key | Type | Meaning | +//! | --- | --- | --- | +//! | `baseRef` | string | The ref the branch was created from | +//! | `createdByWt` | bool | `wt` created the branch, so `wt` may delete it | +//! | `prNumber` | integer | The originating pull request | +//! | `prState` | string | Cached PR state, so listing works offline | +//! | `prTitle` | string | Cached PR title | +//! | `prUrl` | string | Cached PR URL | +//! | `issueNumber` | integer | The linked GitHub issue | +//! | `issueTitle` | string | Cached issue title | +//! | `issueUrl` | string | Cached issue URL | +//! | `issueBrief` | string | The generated implementation brief | +//! +//! Two rules make the namespace safe to share with an embedder: [`read_meta`] +//! maps a missing key to `None`, and it ignores keys it does not know. So +//! *adding* a key never breaks an older reader, and an embedder may keep its +//! own keys in its own namespace without `wt` disturbing them. What is **not** +//! safe is changing what an existing key means — that is what +//! [`SCHEMA_VERSION`] exists to gate, and why [`ensure_schema_supported`] +//! should run before reading or writing. +//! +//! [`clear_meta`] removes the whole `wt.` section, so it also removes +//! keys this build has never heard of. use std::path::Path; diff --git a/src/git/mod.rs b/src/git/mod.rs index ab24817..26977ec 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -1,7 +1,7 @@ //! The Git boundary (spec §4): `gix` for reads, the `git` CLI for mutations and //! network operations. Submodules: //! -//! - [`cli`] — the [`GitCli`](cli::GitCli) subprocess trait + [`RealGit`](cli::RealGit). +//! - [`cli`] — the [`GitCli`] subprocess trait + [`RealGit`]. //! - `ops` — verb-named wrappers over [`GitCli`] for shared mutations. //! - [`discover`] — repository discovery and identity via `gix`. //! - [`porcelain`] — pure parsers for `git` porcelain output. diff --git a/src/lib.rs b/src/lib.rs index 127a26e..7eb8465 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,39 @@ //! and a [`Cx`] (injected I/O, environment, and working directory) and returns //! the process exit code. Keeping the side-effecting handles in `Cx` makes the //! whole dispatch path testable without touching the real terminal. +//! +//! # Embedding `wt` +//! +//! That entry point is the *application*. Embedders skip it and drive the +//! worktree engine directly. The crate is published as `kono-wt`, but the +//! library target is named `wt`, so the API is imported as `wt::…` either way: +//! +//! ```toml +//! kono-wt = { version = "1", default-features = false } +//! ``` +//! +//! Turning the default features off drops the application surface — argument +//! parsing, the TUI, the PR compose flow — and with it `clap`, `ratatui`, +//! `crossterm` and `tokio`. What remains is the engine: +//! +//! - [`worktree::Workspace`] — discover a repository, then enumerate, create +//! and remove worktrees and read or write their metadata. Nothing on it +//! prompts, reads stdin, or writes to stdout; outcomes come back as data. +//! - [`template`] — where worktrees live. The layout is repository +//! configuration, so resolve paths through this module; reimplementing the +//! template makes the two products disagree about where worktrees are. +//! - [`config::wtconfig`] — the `wt..*` metadata contract, plus +//! [`worktree::SCHEMA_VERSION`] and the version gate to check before +//! mutating. +//! - [`worktree::RepoLock`] — the advisory lock that serializes mutations +//! across every `wt` and embedder in one repository. Call +//! [`install_signal_handlers`] once at startup so a signal cannot strand it. +//! +//! `wt` owns only the short, structured generation steps it needs for its own +//! branch and PR proposals (`[agent.generation]`). Running a coding agent on +//! the work itself belongs to the embedder. +//! +//! See the "Using wt as a library" section of the README for a worked example. pub mod agent; #[cfg(feature = "cli")] diff --git a/src/template.rs b/src/template.rs index 7f43555..a56e9c8 100644 --- a/src/template.rs +++ b/src/template.rs @@ -4,6 +4,15 @@ //! variables `{repo_parent}`, `{repo}`, `{repo_root}`, `{branch}`, //! `{branch_slug}`, and `{home}`. [`render`] substitutes them; [`ensure_outside_git`] //! rejects a rendered path that would land inside the `.git` directory. +//! +//! **Embedders must resolve worktree paths through this module.** The template +//! is repository configuration ([`Config::path_template`](crate::config::Config)), +//! so it varies per repository and a user may change it at any time. +//! Hard-coding [`DEFAULT_TEMPLATE`]'s layout — or any other guess at where a +//! worktree lives — makes two tools that share a repository disagree about +//! where its worktrees are. [`Workspace::create`](crate::worktree::Workspace::create) +//! already renders through here and reports the resulting path, which is the +//! easiest way to stay consistent. use std::path::{Path, PathBuf}; diff --git a/src/tui/app.rs b/src/tui/app.rs index a3b5ca4..d458283 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -112,7 +112,7 @@ pub enum StatusKind { /// Identifies the target of a background job so its per-row spinner can be found /// and so a second action on the same target can be refused (issue #46 overhaul). /// Keyed by the row's stable identity (path or branch name) so it survives a -/// re-sort/refresh, mirroring [`App::loaded_paths`]. +/// re-sort/refresh, mirroring how `App` keys its loaded paths. #[derive(Debug, Clone, PartialEq, Eq)] pub enum JobKey { /// A job targeting the worktree at this path (remove, sync, checkout, submodule From ae1c205da787dba83c7c7bbc2fb168438040c680 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Fri, 28 Aug 2026 22:49:24 -0400 Subject: [PATCH 3/3] docs: document using wt as a library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README covered only the CLI, so the embedding surface existed solely as Cargo.toml comments and rustdoc — nothing a consumer would find before depending on the crate. Adds a "Using wt as a library" section covering what karet depends on: consuming the crate with `default-features = false` and what that drops, the `Workspace` API and the no-prompting/no-stdout contract that makes it embeddable, resolving worktree paths through the template module rather than reimplementing the layout, the `wt..*` metadata contract and the `wt.schema` gate, the locking rules (including what a caller must not hold a lock across), and the generation/work split. The worked example is extracted from the README and compiled against the library with `default-features = false`, then run against a real repository, rather than written by hand — which is how the `Option` branch field got caught. Claude-Session: https://claude.ai/code/session_014ce9P64RKbVU6kj3GmA1mo --- README.md | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/README.md b/README.md index 6006913..eaee1d1 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,160 @@ These are the things worth knowing up front; the rest is discoverable from branch that isn't also merged may hold unmerged commits, so it is skipped unless you pass `--force`. The current and default branches are never touched. +## Using wt as a library + +Everything the CLI and TUI do sits on a worktree engine that is usable on its +own. [karet](https://github.com/getkono/karet) drives it directly; the contract +below is what it depends on. + +### Consuming it + +```toml +[dependencies] +kono-wt = { version = "1", default-features = false } +``` + +The package is `kono-wt`, but the library target is named `wt`, so the import +path is `wt::…` either way (the same rename that leaves the installed binary +called `wt`). + +`default-features = false` drops the application surface — argument parsing, the +TUI, the PR compose flow, the agent integration — and with it `clap`, +`clap_complete`, `ratatui`, `crossterm`, `nucleo-matcher`, `futures-util`, +`tokio`, `sendit`, `color-eyre`, `eyre` and `tracing-subscriber`. What remains is +the engine: the worktree service, config, git, branch naming, path templating and +the typed error enum. Turn features back on individually (`cli`, `tui`, `pr`, +`agent`) if you want part of the application surface too. + +### The worktree service + +`wt::worktree::Workspace` is the entry point. Discover a repository, then +enumerate, create and remove worktrees: + +```rust +use std::path::Path; + +use wt::git::RealGit; +use wt::hooks::RealHookRunner; +use wt::worktree::{CreateOptions, Workspace}; +use wt::{Env, install_signal_handlers}; + +fn main() -> Result<(), wt::Error> { + install_signal_handlers(); + + let env = Env::from_real(); + let ws = Workspace::discover(Path::new("."), &env, &RealGit)?; + + // Detached worktrees have no branch, so `branch` is an `Option`. + for worktree in ws.list(&RealGit)? { + let branch = worktree.branch.as_deref().unwrap_or("(detached)"); + println!("{branch}\t{}", worktree.path.display()); + } + + let created = ws.create( + &RealGit, + &RealHookRunner, + &CreateOptions { + branch: "feat/login".into(), + ..CreateOptions::default() + }, + )?; + println!("{}", created.path.display()); + Ok(()) +} +``` + +**The service never prompts and never writes to stdout or stderr.** That is the +property that makes it embeddable: everything a user might need to see comes back +as data on the outcome structs — hook results (`HookOutcome`), what the copy step +did, how submodule initialization went, and whether a removal was forced past the +dirty/unpushed guards. The caller decides how, or whether, to present any of it. +Failures are typed variants of `wt::Error`, not messages. + +`Workspace::create` is idempotent: an existing worktree at the configured target +comes back with `reused: true` rather than an error. + +### Where worktrees live + +Layout is repository configuration, not a convention: + +```rust +use wt::template::{self, DEFAULT_TEMPLATE, TemplateVars}; +``` + +`DEFAULT_TEMPLATE` is `{repo_parent}/{repo}.worktrees/{repo}-{branch_slug}`, but +a repository's `.wt.toml` may set `path_template` to anything, using +`{repo_parent}`, `{repo}`, `{repo_root}`, `{branch}`, `{branch_slug}` and +`{home}`. + +**Resolve paths through this library — never reimplement the template.** Two +tools that guess independently will disagree about where a repository's worktrees +are, and the user is the one who finds out. `Workspace::create` already renders +through `template::render` and reports the resulting path, which is the simplest +way to stay consistent; `template::render` itself is there for resolving a path +before creating anything. + +### The metadata contract + +`wt` records per-branch state in the repository's git config under +`wt..*`. Read it with `Workspace::read_meta` and write it with +`Workspace::write_meta`: + +| Key | Meaning | +| --- | --- | +| `baseRef` | The ref the branch was created from | +| `createdByWt` | `wt` created the branch, so `wt` may delete it | +| `prNumber`, `prState`, `prTitle`, `prUrl` | The originating PR, cached so listing works offline | +| `issueNumber`, `issueTitle`, `issueUrl` | The linked GitHub issue | +| `issueBrief` | The implementation brief `wt issue` generated | + +Reads map a missing key to `None` and ignore unknown keys, so *adding* a key +never breaks an older reader, and an embedder can keep its own state in its own +config namespace without `wt` disturbing it. `MetaUpdate` writes only its `Some` +fields, so refreshing one key cannot clobber the rest. + +Changing what an existing key *means* is the case that needs coordination, and +`wt.schema` gates it. A repository with no `wt.schema` is version 1; +`wt::worktree::SCHEMA_VERSION` is what this build understands. A repository +stamped **higher** than that is refused with `Error::SchemaTooNew` rather than +read with the wrong meanings — surface it as "upgrade the tool", not as a +corrupt repository. `Workspace::discover` performs this check, and the mutating +operations repeat it. + +### Locking + +Mutations are serialized across every `wt` process and embedder sharing a +repository by an advisory lock — a `wt-mutation.lock` marker in the common git +directory, waited on for up to 10 seconds before failing with +`Error::LockUnavailable`. + +`create`, `remove`, `write_meta` and `clear_meta` take it internally, so **do not +hold a lock across a call to them** — it is not reentrant, and doing so waits out +the full timeout and then fails. Take one yourself, via `Workspace::lock`, only to +make a longer read-check-write sequence over `wt.*` metadata atomic; drop it +before calling back into the service. + +Hooks deliberately run *outside* the lock, so a `post_create` or `pre_remove` +hook that re-enters `wt` cannot deadlock against the operation that invoked it. + +Call `wt::install_signal_handlers()` once, early. The lock is released on drop, +which a terminating signal skips — stranding the marker so the next mutation +waits out its whole timeout. The handlers clean it up and re-raise. (`SIGHUP` is +not covered.) + +### Generation and work + +`wt` owns the short, structured generation steps it needs for its own proposals — +the `wt pr open --ai` draft and the `wt issue` branch/brief — configured under +`[agent.generation]`. It deliberately owns nothing else: running a coding agent on +the work belongs to the embedder, which is why `[agent.work]` is rejected rather +than accepted and ignored. + +`wt issue` reflects the same split. It creates the worktree, records the issue +link and persists the brief, then stops. Handing the work to an agent is the +embedder's step, and the persisted `issueBrief` means it need not pay to +regenerate what `wt` already produced. + ## Development ### Prerequisites