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
154 changes: 154 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<branch>.*`. 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
Expand Down
29 changes: 29 additions & 0 deletions src/config/wtconfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@
//! Metadata is keyed by branch (`[wt "<branch>"]`), 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.<branch>.*` 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.<branch>` section, so it also removes
//! keys this build has never heard of.

use std::path::Path;

Expand Down
2 changes: 1 addition & 1 deletion src/git/mod.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 33 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<branch>.*` 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")]
Expand Down
9 changes: 9 additions & 0 deletions src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
2 changes: 1 addition & 1 deletion src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions src/worktree/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
//! 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
//! thin interactive wrappers over this module; embedders (karet) call it
//! 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;
Expand All @@ -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::{
Expand Down
1 change: 1 addition & 0 deletions src/worktree/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading