Skip to main content

jj_hooks/
init.rs

1//! `jj-hooks init` — interactive setup for the user-level config.
2//!
3//! Three yes/no prompts:
4//! 1. Install a `jj push` alias that delegates to `jj-hooks push`.
5//! 2. Auto-advance bookmarks when hooks modify files.
6//! 3. Install jjui actions/bindings so `jj-hp push` is reachable from
7//!    inside [jjui](https://github.com/idursun/jjui).
8//!
9//! The first two write to the user-level jj config via `jj config set`.
10//! The third merges into `~/.config/jjui/config.toml` (or the path passed
11//! via `JJUI_CONFIG_DIR`). Prompts go through the [`Prompter`] trait so
12//! tests can script answers.
13
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use crate::error::{JjHooksError, Result};
18use crate::runner::Runner;
19
20pub trait Prompter {
21    fn confirm(&mut self, message: &str, default: bool) -> Result<bool>;
22}
23
24/// A prompter that returns pre-canned answers in order. Used in tests.
25pub struct ScriptedPrompter {
26    answers: std::vec::IntoIter<bool>,
27}
28
29impl ScriptedPrompter {
30    pub fn new(answers: Vec<bool>) -> Self {
31        Self {
32            answers: answers.into_iter(),
33        }
34    }
35}
36
37impl Prompter for ScriptedPrompter {
38    fn confirm(&mut self, _message: &str, default: bool) -> Result<bool> {
39        Ok(self.answers.next().unwrap_or(default))
40    }
41}
42
43/// Interactive prompter backed by dialoguer.
44pub struct InteractivePrompter;
45
46impl Prompter for InteractivePrompter {
47    fn confirm(&mut self, message: &str, default: bool) -> Result<bool> {
48        dialoguer::Confirm::new()
49            .with_prompt(message)
50            .default(default)
51            .interact()
52            .map_err(|e| JjHooksError::Io(std::io::Error::other(e.to_string())))
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct InitPlan {
58    pub install_alias: bool,
59    pub advance_bookmarks: bool,
60    pub install_jjui_actions: bool,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub struct AddedItems {
65    pub added_jj_push: bool,
66    pub added_jj_push_selected: bool,
67    pub added_binding_x_p: bool,
68    pub added_binding_x_p_caps: bool,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct InitOutcome {
73    pub alias_set: bool,
74    pub advance_bookmarks_set: bool,
75    pub jjui_actions_added: AddedItems,
76}
77
78/// Build an [`InitPlan`] by asking the user (via `prompter`) which optional
79/// integrations to install. `detected_runner` is for informational printing
80/// — the plan itself is the same regardless.
81pub fn plan(detected_runner: Option<Runner>, prompter: &mut dyn Prompter) -> Result<InitPlan> {
82    if let Some(runner) = detected_runner {
83        tracing::info!("detected hook runner: {}", runner.bin());
84    } else {
85        tracing::info!("no hook-runner config detected at workspace root");
86    }
87
88    let install_alias = prompter.confirm(
89        "Set up `jj push` alias so it runs hooks before pushing?",
90        false,
91    )?;
92    let advance_bookmarks = prompter.confirm(
93        "Auto-advance bookmarks to fixup commits when hooks modify files?",
94        false,
95    )?;
96    let install_jjui_actions = prompter.confirm(
97        "Install jjui actions/bindings so `jj-hp push` is reachable from inside jjui?",
98        false,
99    )?;
100
101    Ok(InitPlan {
102        install_alias,
103        advance_bookmarks,
104        install_jjui_actions,
105    })
106}
107
108/// Apply an [`InitPlan`] by invoking `jj config set --user` for each
109/// requested jj key, and merging jjui actions/bindings into the jjui
110/// config file when requested.
111///
112/// - `jj_config_path`: if `Some`, `JJ_CONFIG` is set to that path for the
113///   subprocess so writes are scoped (used in tests). `None` writes to
114///   the real user config.
115/// - `jjui_config_path`: where to merge the jjui actions/bindings. `None`
116///   resolves to the canonical path (`$JJUI_CONFIG_DIR/config.toml` or
117///   `~/.config/jjui/config.toml`).
118pub fn apply(
119    plan: &InitPlan,
120    jj_config_path: Option<&Path>,
121    jjui_config_path: Option<&Path>,
122) -> Result<InitOutcome> {
123    let mut outcome = InitOutcome {
124        alias_set: false,
125        advance_bookmarks_set: false,
126        jjui_actions_added: AddedItems::default(),
127    };
128
129    if plan.install_alias {
130        jj_config_set(
131            "aliases.push",
132            r#"["util", "exec", "--", "jj-hp", "push"]"#,
133            jj_config_path,
134        )?;
135        outcome.alias_set = true;
136    }
137
138    if plan.advance_bookmarks {
139        jj_config_set("jj-hooks.advance-bookmarks", "true", jj_config_path)?;
140        outcome.advance_bookmarks_set = true;
141    }
142
143    if plan.install_jjui_actions {
144        let path = match jjui_config_path {
145            Some(p) => p.to_path_buf(),
146            None => default_jjui_config_path()?,
147        };
148        outcome.jjui_actions_added = apply_jjui_config(&path)?;
149    }
150
151    Ok(outcome)
152}
153
154fn jj_config_set(key: &str, value: &str, config_path: Option<&Path>) -> Result<()> {
155    let mut cmd = Command::new("jj");
156    cmd.args(["config", "set", "--user", key, value]);
157
158    if let Some(path) = config_path {
159        cmd.env("JJ_CONFIG", path);
160    }
161
162    let output = cmd.output()?;
163    if !output.status.success() {
164        return Err(JjHooksError::JjFailed {
165            status: output.status.code().unwrap_or(-1),
166            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
167        });
168    }
169    Ok(())
170}
171
172/// Resolve the canonical jjui config path: `$JJUI_CONFIG_DIR/config.toml`
173/// if set, otherwise `$XDG_CONFIG_HOME/jjui/config.toml`, otherwise
174/// `~/.config/jjui/config.toml`.
175fn default_jjui_config_path() -> Result<PathBuf> {
176    if let Some(dir) = std::env::var_os("JJUI_CONFIG_DIR") {
177        return Ok(PathBuf::from(dir).join("config.toml"));
178    }
179    let base = if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
180        PathBuf::from(xdg)
181    } else {
182        let home = std::env::var_os("HOME").ok_or_else(|| {
183            JjHooksError::Io(std::io::Error::other(
184                "neither JJUI_CONFIG_DIR, XDG_CONFIG_HOME, nor HOME is set",
185            ))
186        })?;
187        PathBuf::from(home).join(".config")
188    };
189    Ok(base.join("jjui").join("config.toml"))
190}
191
192/// Read the jjui config at `path` (treating a missing file as empty),
193/// merge in our actions/bindings, and write back. Returns which items
194/// were added (none if everything was already present).
195fn apply_jjui_config(path: &Path) -> Result<AddedItems> {
196    let existing = match std::fs::read_to_string(path) {
197        Ok(s) => s,
198        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
199        Err(e) => return Err(e.into()),
200    };
201
202    let (merged, added) = add_jjui_actions(&existing)?;
203
204    if let Some(parent) = path.parent() {
205        std::fs::create_dir_all(parent)?;
206    }
207    std::fs::write(path, merged)?;
208    Ok(added)
209}
210
211/// Merge `jj-hp-push` / `jj-hp-push-selected` actions and their `x p` / `x P`
212/// bindings into a jjui config TOML string.
213///
214/// Three cases per action:
215/// 1. Neither old nor new name present → add the new one.
216/// 2. New name already present → leave alone (idempotent).
217/// 3. Old `jj-push` / `jj-push-selected` name present with a lua body
218///    matching one of our known auto-installed forms → rename in place
219///    (action name + any bindings pointing at it, plus binding desc).
220/// 4. Old name present with a custom lua body the user wrote → leave
221///    alone. The user owns that name.
222///
223/// Returns the new TOML and a record of which items were newly added.
224/// Renames count as 0 added (they're migrations, not adds).
225pub fn add_jjui_actions(existing: &str) -> Result<(String, AddedItems)> {
226    let mut doc: toml::Table = if existing.trim().is_empty() {
227        toml::Table::new()
228    } else {
229        existing
230            .parse()
231            .map_err(|e: toml::de::Error| JjHooksError::Parse(format!("jjui config: {e}")))?
232    };
233
234    let mut added = AddedItems::default();
235
236    // -- Actions ------------------------------------------------------------
237    let actions = doc
238        .entry("actions")
239        .or_insert_with(|| toml::Value::Array(Vec::new()));
240    let actions_arr = actions
241        .as_array_mut()
242        .ok_or_else(|| JjHooksError::Parse("jjui config: `actions` is not an array".into()))?;
243
244    let push_state = classify_action(actions_arr, NEW_PUSH_NAME, OLD_PUSH_NAME, &push_lua_forms());
245    let push_selected_state = classify_action(
246        actions_arr,
247        NEW_PUSH_SELECTED_NAME,
248        OLD_PUSH_SELECTED_NAME,
249        &push_selected_lua_forms(),
250    );
251
252    // Selected first: same frequency-ordering rationale as jj-gt's
253    // SPECS array. jjui's `x`-prefix overlay surfaces candidates
254    // top-down in config order, so the daily "ship the focused
255    // bookmark" keystroke should land at index 0. Whole-stack
256    // push (`x P`) is the less-common operation and sits below.
257    apply_action(
258        actions_arr,
259        push_selected_state,
260        NEW_PUSH_SELECTED_NAME,
261        push_selected_lua_forms()[0].to_owned(),
262        &mut added.added_jj_push_selected,
263    );
264    apply_action(
265        actions_arr,
266        push_state,
267        NEW_PUSH_NAME,
268        push_lua_forms()[0].to_owned(),
269        &mut added.added_jj_push,
270    );
271
272    // -- Bindings -----------------------------------------------------------
273    let bindings = doc
274        .entry("bindings")
275        .or_insert_with(|| toml::Value::Array(Vec::new()));
276    let bindings_arr = bindings
277        .as_array_mut()
278        .ok_or_else(|| JjHooksError::Parse("jjui config: `bindings` is not an array".into()))?;
279
280    // For each binding pointing at an old action name, rename its `action`
281    // field to the new name and update `desc`. (This is independent of
282    // whether the action itself got renamed — there could be a stale
283    // binding referencing an action we already renamed.)
284    for b in bindings_arr.iter_mut() {
285        let Some(action) = b.get("action").and_then(|v| v.as_str()) else {
286            continue;
287        };
288        if action == OLD_PUSH_NAME && push_state == ActionState::OldManaged {
289            let table = b.as_table_mut().unwrap();
290            table.insert("action".into(), toml::Value::String(NEW_PUSH_NAME.into()));
291            table.insert("desc".into(), toml::Value::String(NEW_PUSH_DESC.into()));
292        } else if action == OLD_PUSH_SELECTED_NAME && push_selected_state == ActionState::OldManaged
293        {
294            let table = b.as_table_mut().unwrap();
295            table.insert(
296                "action".into(),
297                toml::Value::String(NEW_PUSH_SELECTED_NAME.into()),
298            );
299            table.insert(
300                "desc".into(),
301                toml::Value::String(NEW_PUSH_SELECTED_DESC.into()),
302            );
303        }
304    }
305
306    // Migrate `seq` for managed bindings whose key sequence matches
307    // a previous value we installed but not the current one. The
308    // 2026-05 swap moved `jj-hp-push` to `x P` and `jj-hp-push-
309    // selected` to `x p`; older configs need updating.
310    //
311    // Detection key: action name is ours AND `seq` is in our
312    // installed-history. Custom keys the user picked are left alone
313    // because they're not in our history list.
314    migrate_seq_for_managed_binding(
315        bindings_arr,
316        NEW_PUSH_NAME,
317        &push_seq_history(),
318        NEW_PUSH_DESC,
319    );
320    migrate_seq_for_managed_binding(
321        bindings_arr,
322        NEW_PUSH_SELECTED_NAME,
323        &push_selected_seq_history(),
324        NEW_PUSH_SELECTED_DESC,
325    );
326
327    // Add any missing bindings (idempotent). Selected-bookmark
328    // binding comes first to match the action ordering — keeps
329    // jjui's `x`-prefix overlay listing daily-use keys at the top
330    // and whole-stack/recovery flows below.
331    if !bindings_has_action(bindings_arr, NEW_PUSH_SELECTED_NAME) {
332        bindings_arr.push(make_binding(
333            NEW_PUSH_SELECTED_NAME,
334            &push_selected_seq_history()[0],
335            "revisions",
336            NEW_PUSH_SELECTED_DESC,
337        ));
338        added.added_binding_x_p_caps = true;
339    }
340    if !bindings_has_action(bindings_arr, NEW_PUSH_NAME) {
341        bindings_arr.push(make_binding(
342            NEW_PUSH_NAME,
343            &push_seq_history()[0],
344            "revisions",
345            NEW_PUSH_DESC,
346        ));
347        added.added_binding_x_p = true;
348    }
349
350    let serialized = toml::to_string_pretty(&doc)
351        .map_err(|e| JjHooksError::Parse(format!("serializing jjui config: {e}")))?;
352
353    Ok((serialized, added))
354}
355
356const NEW_PUSH_NAME: &str = "jj-hp-push";
357const NEW_PUSH_SELECTED_NAME: &str = "jj-hp-push-selected";
358const OLD_PUSH_NAME: &str = "jj-push";
359const OLD_PUSH_SELECTED_NAME: &str = "jj-push-selected";
360const NEW_PUSH_DESC: &str = "jj-hp push";
361const NEW_PUSH_SELECTED_DESC: &str = "jj-hp push selected bookmark(s)";
362
363/// Every key sequence we have ever installed for `jj-hp-push`,
364/// most-recent first. The current value is index 0.
365///
366/// The 2026-05 swap moved the "push everything" action from
367/// `x p` (lowercase) to `x P` (uppercase) so that the more
368/// commonly-used `jj-hp-push-selected` could take the easier
369/// keypress. `apply_jjui_config`'s migration pass detects the
370/// previous sequence and updates it in place when the binding's
371/// name is ours and its lua body is still managed.
372fn push_seq_history() -> Vec<Vec<&'static str>> {
373    vec![
374        vec!["x", "P"], // current — push entire @-ancestor stack
375        vec!["x", "p"], // pre-2026-05 swap
376    ]
377}
378
379/// Every key sequence we have ever installed for
380/// `jj-hp-push-selected`, most-recent first.
381fn push_selected_seq_history() -> Vec<Vec<&'static str>> {
382    vec![
383        vec!["x", "p"], // current — push just the focused bookmark
384        vec!["x", "P"], // pre-2026-05 swap
385    ]
386}
387
388/// Known lua bodies we have auto-installed for `jj-hp-push` historically.
389/// Index 0 is the current form; later indices are older forms we still
390/// recognize as ours (so we can safely rename them).
391fn push_lua_forms() -> Vec<&'static str> {
392    vec![
393        // Current form.
394        "  jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\")\n  revisions.refresh()\n",
395        // Pre-jj-hp form (called `jj push` via the alias).
396        "  jj_async(\"push\")\n  revisions.refresh()\n",
397    ]
398}
399
400fn push_selected_lua_forms() -> Vec<&'static str> {
401    vec![
402        // Current form.
403        "  jj_async(\"util\", \"exec\", \"--\", \"jj-hp\", \"push\", \"-r\", context.commit_id())\n  revisions.refresh()\n",
404        // Pre-jj-hp form.
405        "  jj_async(\"push\", \"-r\", context.commit_id())\n  revisions.refresh()\n",
406    ]
407}
408
409/// What we found when classifying an action by its name + lua body.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411enum ActionState {
412    /// Neither name present anywhere — we need to add the new action.
413    Missing,
414    /// New name already present — leave alone.
415    AlreadyNewName,
416    /// Old name present, lua matches one of our known forms — safe to rename.
417    OldManaged,
418    /// Old name present, lua is custom (user-owned) — leave alone.
419    OldUserOwned,
420}
421
422fn classify_action(
423    actions: &[toml::Value],
424    new_name: &str,
425    old_name: &str,
426    known_lua: &[&str],
427) -> ActionState {
428    let mut found_new = false;
429    let mut found_old: Option<&str> = None;
430    for a in actions {
431        let Some(name) = a.get("name").and_then(|v| v.as_str()) else {
432            continue;
433        };
434        if name == new_name {
435            found_new = true;
436        }
437        if name == old_name {
438            found_old = a.get("lua").and_then(|v| v.as_str());
439        }
440    }
441    if found_new {
442        return ActionState::AlreadyNewName;
443    }
444    match found_old {
445        None => ActionState::Missing,
446        Some(lua) if known_lua.contains(&lua) => ActionState::OldManaged,
447        Some(_) => ActionState::OldUserOwned,
448    }
449}
450
451fn apply_action(
452    actions: &mut Vec<toml::Value>,
453    state: ActionState,
454    new_name: &str,
455    new_lua: String,
456    added_flag: &mut bool,
457) {
458    match state {
459        ActionState::Missing | ActionState::OldUserOwned => {
460            // Either we own the slot and need to add it, or the user has
461            // co-opted the old name with custom lua — in both cases we
462            // want to add (or skip if user-owned and we have no slot).
463            if matches!(state, ActionState::Missing) {
464                let mut t = toml::Table::new();
465                t.insert("name".into(), toml::Value::String(new_name.into()));
466                t.insert("lua".into(), toml::Value::String(new_lua));
467                actions.push(toml::Value::Table(t));
468                *added_flag = true;
469            }
470        }
471        ActionState::AlreadyNewName => {
472            // Idempotent: leave alone.
473        }
474        ActionState::OldManaged => {
475            // Rename in place. Find the old entry and rename it; also
476            // refresh the lua to the current form.
477            for a in actions.iter_mut() {
478                let Some(table) = a.as_table_mut() else {
479                    continue;
480                };
481                let name = table
482                    .get("name")
483                    .and_then(|v| v.as_str())
484                    .map(|s| s.to_owned());
485                let old_match = match name.as_deref() {
486                    Some("jj-push") if new_name == NEW_PUSH_NAME => true,
487                    Some("jj-push-selected") if new_name == NEW_PUSH_SELECTED_NAME => true,
488                    _ => false,
489                };
490                if old_match {
491                    table.insert("name".into(), toml::Value::String(new_name.into()));
492                    table.insert("lua".into(), toml::Value::String(new_lua));
493                    break;
494                }
495            }
496        }
497    }
498}
499
500fn bindings_has_action(arr: &[toml::Value], action: &str) -> bool {
501    arr.iter()
502        .any(|v| v.get("action").and_then(|n| n.as_str()) == Some(action))
503}
504
505/// In-place migration: when a binding's `action` matches `name` and
506/// its `seq` is a sequence we previously installed for that action
507/// (anything in `seq_history` past index 0), rewrite the `seq` to
508/// the current value (index 0) and refresh `desc`.
509///
510/// User-customized sequences are NOT migrated — they're not in
511/// `seq_history`, so the detection falls through. Idempotent: a
512/// binding whose seq is already the current value is left alone.
513fn migrate_seq_for_managed_binding(
514    bindings: &mut [toml::Value],
515    name: &str,
516    seq_history: &[Vec<&'static str>],
517    desc: &str,
518) {
519    if seq_history.len() < 2 {
520        return; // No prior versions to migrate from.
521    }
522    let current = &seq_history[0];
523    let prior: std::collections::HashSet<&[&str]> =
524        seq_history[1..].iter().map(|s| s.as_slice()).collect();
525    for b in bindings.iter_mut() {
526        let Some(action) = b.get("action").and_then(|v| v.as_str()) else {
527            continue;
528        };
529        if action != name {
530            continue;
531        }
532        let Some(existing_seq) = b.get("seq").and_then(|v| v.as_array()) else {
533            continue;
534        };
535        let as_strs: Vec<&str> = existing_seq.iter().filter_map(|v| v.as_str()).collect();
536        if !prior.contains(as_strs.as_slice()) {
537            // Either the seq is already current, or the user
538            // picked a custom key. Either way, no migration.
539            continue;
540        }
541        let table = b.as_table_mut().unwrap();
542        table.insert(
543            "seq".into(),
544            toml::Value::Array(
545                current
546                    .iter()
547                    .map(|s| toml::Value::String((*s).into()))
548                    .collect(),
549            ),
550        );
551        table.insert("desc".into(), toml::Value::String(desc.into()));
552    }
553}
554
555fn make_binding(action: &str, seq: &[&str], scope: &str, desc: &str) -> toml::Value {
556    let mut t = toml::Table::new();
557    t.insert("action".into(), toml::Value::String(action.into()));
558    t.insert(
559        "seq".into(),
560        toml::Value::Array(
561            seq.iter()
562                .map(|s| toml::Value::String((*s).into()))
563                .collect(),
564        ),
565    );
566    t.insert("scope".into(), toml::Value::String(scope.into()));
567    t.insert("desc".into(), toml::Value::String(desc.into()));
568    toml::Value::Table(t)
569}