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 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 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,