Skip to main content

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}