wt/lib.rs
1//! `wt` — a Git worktree and GitHub PR manager (library crate).
2//!
3//! All real logic lives here so it is unit-testable and counted by coverage;
4//! `src/main.rs` is a thin entry point. See `spec.md` for the full behavior
5//! specification.
6//!
7//! The single entry point is [`run`], which takes the command-line arguments
8//! and a [`Cx`] (injected I/O, environment, and working directory) and returns
9//! the process exit code. Keeping the side-effecting handles in `Cx` makes the
10//! whole dispatch path testable without touching the real terminal.
11//!
12//! # Embedding `wt`
13//!
14//! That entry point is the *application*. Embedders skip it and drive the
15//! worktree engine directly. The crate is published as `kono-wt`, but the
16//! library target is named `wt`, so the API is imported as `wt::…` either way:
17//!
18//! ```toml
19//! kono-wt = { version = "1", default-features = false }
20//! ```
21//!
22//! Turning the default features off drops the application surface — argument
23//! parsing, the TUI, the PR compose flow — and with it `clap`, `ratatui`,
24//! `crossterm` and `tokio`. What remains is the engine:
25//!
26//! - [`worktree::Workspace`] — discover a repository, then enumerate, create
27//! and remove worktrees and read or write their metadata. Nothing on it
28//! prompts, reads stdin, or writes to stdout; outcomes come back as data.
29//! - [`template`] — where worktrees live. The layout is repository
30//! configuration, so resolve paths through this module; reimplementing the
31//! template makes the two products disagree about where worktrees are.
32//! - [`config::wtconfig`] — the `wt.<branch>.*` metadata contract, plus
33//! [`worktree::SCHEMA_VERSION`] and the version gate to check before
34//! mutating.
35//! - [`worktree::RepoLock`] — the advisory lock that serializes mutations
36//! across every `wt` and embedder in one repository. Taking it re-validates
37//! the metadata schema under the lock, so an acquisition is also the version
38//! gate. Call [`install_signal_handlers`] once at startup so a signal cannot
39//! strand it.
40//!
41//! `wt` owns only the short, structured generation steps it needs for its own
42//! branch and PR proposals (`[agent.generation]`). Running a coding agent on
43//! the work itself belongs to the embedder.
44//!
45//! See the "Using wt as a library" section of the README for a worked example.
46
47pub mod agent;
48#[cfg(feature = "cli")]
49pub(crate) mod cli;
50#[cfg(feature = "cli")]
51pub(crate) mod commands;
52pub mod config;
53pub mod copy;
54pub mod cx;
55pub mod error;
56pub mod gh;
57pub mod git;
58pub mod hooks;
59#[cfg(feature = "tui")]
60pub mod keys;
61pub mod model;
62pub mod naming;
63pub mod output;
64/// Human-facing progress for slow foreground operations (CLI only).
65#[cfg(feature = "cli")]
66pub(crate) mod progress;
67pub mod query;
68pub mod slug;
69pub mod template;
70pub mod time;
71#[cfg(feature = "tui")]
72pub mod tui;
73pub mod util;
74pub mod version;
75pub mod worktree;
76
77#[cfg(test)]
78mod testutil;
79
80pub use cx::{Cx, Env, Stream};
81pub use error::{Error, Result};
82
83/// Arms the signal handlers that release the advisory repository lock.
84///
85/// The lock taken by [`worktree::Workspace`] (and every other `gix` tempfile)
86/// is released by `Drop`, which a terminating signal skips — stranding
87/// `wt-mutation.lock` so the next mutating command waits out its full timeout
88/// and then fails. This installs handlers for `SIGINT`, `SIGTERM` and
89/// `SIGQUIT` that remove those files and then re-raise the signal with its
90/// default disposition, so the process still terminates as the caller expects.
91/// `SIGHUP` is not covered.
92///
93/// The `wt` binary calls this at startup. Embedders that drive the worktree
94/// API themselves should call it once, early: it must run before the first
95/// tempfile is created, and only the first call takes effect.
96pub fn install_signal_handlers() {
97 // Route through `gix-lock`'s own re-export rather than a separate
98 // `gix_tempfile::` path. The tempfile registry and the handler mode are
99 // per-crate-instance statics, so arming any copy other than the one
100 // `gix-lock` registers the lock with would silently do nothing.
101 gix_lock::tempfile::signal::setup(Default::default());
102}
103
104/// Runs `wt` with the given command-line arguments (excluding `argv[0]`),
105/// writing through the provided [`Cx`], and returns the process exit code.
106#[cfg(feature = "cli")]
107pub fn run(args: Vec<String>, cx: &mut Cx) -> u8 {
108 let result = cli::dispatch(args, cx);
109 finish(result, &mut cx.err)
110}
111
112/// Maps a command result to an exit code, reporting any error to `err`.
113#[cfg(feature = "cli")]
114fn finish(result: Result<u8>, err: &mut Stream) -> u8 {
115 match result {
116 Ok(code) => code,
117 Err(e) => {
118 let _ = err.line(&format!("error: {e}"));
119 e.exit_code()
120 }
121 }
122}
123
124// The dispatch tests exercise the full application surface (`run` with no
125// subcommand reaches the TUI), so they need the default feature set.
126#[cfg(all(test, feature = "tui"))]
127mod tests {
128 use super::*;
129 use crate::testutil::test_cx;
130
131 #[test]
132 fn finish_passes_through_success_code() {
133 let mut t = test_cx(&[], "/tmp");
134 assert_eq!(finish(Ok(0), &mut t.cx.err), 0);
135 assert_eq!(finish(Ok(1), &mut t.cx.err), 1);
136 assert!(t.err.contents().is_empty());
137 }
138
139 #[test]
140 fn finish_reports_error_to_stderr_and_maps_code() {
141 let mut t = test_cx(&[], "/tmp");
142 let code = finish(Err(Error::usage("bad flag")), &mut t.cx.err);
143 assert_eq!(code, 2);
144 assert_eq!(t.err.contents(), "error: bad flag\n");
145 assert!(t.out.contents().is_empty());
146 }
147
148 #[test]
149 fn run_help_exits_zero_via_clap() {
150 let mut t = test_cx(&[], "/tmp");
151 assert_eq!(run(vec!["--help".to_string()], &mut t.cx), 0);
152 assert!(t.out.contents().contains("Usage"));
153 }
154
155 #[test]
156 fn run_maps_command_error_to_exit_code() {
157 // No subcommand launches the TUI, which fails at discovery from a
158 // non-repo dir: exit 1 with the NotInRepo message.
159 let mut t = test_cx(&[], "/tmp");
160 assert_eq!(run(vec![], &mut t.cx), 1);
161 assert!(t.err.contents().contains("not in a git repository"));
162 }
163}
164
165#[cfg(test)]
166mod signal_handler_tests {
167 /// Arming must be safe to repeat: only the first call takes effect, and a
168 /// second must not panic or re-register.
169 #[test]
170 fn installing_signal_handlers_is_idempotent() {
171 super::install_signal_handlers();
172 super::install_signal_handlers();
173 }
174}