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
17use anyhow::Context as _;
18use crossterm::{execute, terminal};
19
20/// Resolve the editor command to use for editing commit messages.
21///
22/// Walks git's canonical editor lookup chain:
23/// 1. `GIT_EDITOR` environment variable
24/// 2. `core.editor` git config setting
25/// 3. `VISUAL` environment variable
26/// 4. `EDITOR` environment variable
27/// 5. Fallback: `"vi"`
28fn resolve_editor(repo: &impl GitRepo) -> String {
29    if let Ok(e) = std::env::var("GIT_EDITOR") {
30        return e.trim().to_string();
31    }
32
33    if let Some(e) = repo.get_config_string("core.editor") {
34        return e.trim().to_string();
35    }
36
37    for var in ["VISUAL", "EDITOR"] {
38        if let Ok(e) = std::env::var(var) {
39            return e.trim().to_string();
40        }
41    }
42
43    "vi".to_string()
44}
45
46/// Suspend the TUI, open `path` in the configured editor, then restore the TUI.
47///
48/// The editor command may include arguments (e.g. `"emacs -nw"`) — they are
49/// split on whitespace and forwarded before the file path.  Works for both
50/// terminal editors (e.g. `vim`) and GUI editors that manage their own window
51/// (e.g. `code --wait`).  The TUI is restored unconditionally so the app is
52/// never left in a broken state.
53fn launch_editor(repo: &impl GitRepo, path: &std::path::Path) -> anyhow::Result<()> {
54    let editor_cmd = resolve_editor(repo);
55    let mut parts = editor_cmd.split_whitespace();
56    let prog = parts
57        .next()
58        .ok_or_else(|| anyhow::anyhow!("editor command is empty"))?;
59    let args: Vec<&str> = parts.collect();
60
61    // Suspend TUI before handing the terminal to the editor.
62    terminal::disable_raw_mode().context("failed to disable raw mode")?;
63    execute!(std::io::stdout(), terminal::LeaveAlternateScreen)
64        .context("failed to leave alternate screen")?;
65
66    let status = std::process::Command::new(prog)
67        .args(&args)
68        .arg(path)
69        .status();
70
71    // Restore TUI unconditionally so the app is never left in a broken state.
72    let _ = terminal::enable_raw_mode();
73    let _ = execute!(std::io::stdout(), terminal::EnterAlternateScreen);
74
75    let status = status.with_context(|| format!("failed to launch editor `{prog}`"))?;
76    if !status.success() {
77        anyhow::bail!("editor exited with {status}");
78    }
79    Ok(())
80}
81
82/// Open `message` in the configured editor and return the edited result.
83pub fn edit_message_in_editor(repo: &impl GitRepo, message: &str) -> anyhow::Result<String> {
84    use std::io::Write as _;
85
86    let mut tmpfile =
87        tempfile::NamedTempFile::new().context("failed to create temp file for commit message")?;
88    write!(tmpfile, "{message}").context("failed to write commit message to temp file")?;
89
90    launch_editor(repo, tmpfile.path())?;
91
92    let edited =
93        std::fs::read_to_string(tmpfile.path()).context("failed to read edited commit message")?;
94    Ok(edited.trim().to_string() + "\n")
95}
96
97/// Open an existing working-tree file in the configured editor.
98///
99/// `path` should be the absolute path to the file.  Returns when the editor
100/// process exits.
101pub fn open_file_in_editor(repo: &impl GitRepo, path: &std::path::Path) -> anyhow::Result<()> {
102    launch_editor(repo, path)
103}