git_tailor/editor.rs
1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::repo::GitRepo;
16
17/// Resolve the editor command to use for editing commit messages.
18///
19/// Walks git's canonical editor lookup chain:
20/// 1. `GIT_EDITOR` environment variable
21/// 2. `core.editor` git config setting
22/// 3. `VISUAL` environment variable
23/// 4. `EDITOR` environment variable
24/// 5. Fallback: `"vi"`
25fn resolve_editor(repo: &impl GitRepo) -> String {
26 if let Ok(e) = std::env::var("GIT_EDITOR") {
27 return e.trim().to_string();
28 }
29
30 if let Some(e) = repo.get_config_string("core.editor") {
31 return e.trim().to_string();
32 }
33
34 for var in ["VISUAL", "EDITOR"] {
35 if let Ok(e) = std::env::var(var) {
36 return e.trim().to_string();
37 }
38 }
39
40 "vi".to_string()
41}
42
43/// Open `message` in the configured editor and return the edited result.
44///
45/// Suspends the TUI (disables raw mode, leaves the alternate screen) before
46/// launching the editor, then restores it unconditionally before returning.
47/// Works for both terminal-UI editors (e.g. `vim`, `emacs -nw`) and GUI
48/// editors that manage their own window (e.g. `code --wait`).
49///
50/// The editor command may include arguments (e.g. `"emacs -nw"`) — they are
51/// split on whitespace and forwarded before the temp-file path.
52pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Result<String> {
53 use anyhow::Context;
54 use crossterm::{execute, terminal};
55 use std::io::Write as _;
56
57 let mut tmpfile =
58 tempfile::NamedTempFile::new().context("failed to create temp file for commit message")?;
59 write!(tmpfile, "{message}").context("failed to write commit message to temp file")?;
60
61 let editor_cmd = resolve_editor(repo);
62 let mut parts = editor_cmd.split_whitespace();
63 let prog = parts
64 .next()
65 .ok_or_else(|| anyhow::anyhow!("editor command is empty"))?;
66 let args: Vec<&str> = parts.collect();
67
68 // Suspend TUI before handing the terminal to the editor.
69 terminal::disable_raw_mode().context("failed to disable raw mode")?;
70 execute!(std::io::stdout(), terminal::LeaveAlternateScreen)
71 .context("failed to leave alternate screen")?;
72
73 let status = std::process::Command::new(prog)
74 .args(&args)
75 .arg(tmpfile.path())
76 .status();
77
78 // Restore TUI unconditionally so the app is never left in a broken state.
79 let _ = terminal::enable_raw_mode();
80 let _ = execute!(std::io::stdout(), terminal::EnterAlternateScreen);
81
82 let status = status.with_context(|| format!("failed to launch editor `{prog}`"))?;
83 if !status.success() {
84 anyhow::bail!("editor exited with {status}");
85 }
86
87 let edited =
88 std::fs::read_to_string(tmpfile.path()).context("failed to read edited commit message")?;
89 Ok(edited.trim().to_string() + "\n")
90}