Skip to main content

si_git_commit/
workflow.rs

1use crate::config::Config;
2use crate::error::{Result, SiError};
3use crate::git::Git;
4use crate::llm::Llm;
5use crate::prompt::full_commit_text;
6use std::io::{self, Write};
7
8#[derive(Debug, Clone, Default)]
9pub struct WorkflowOptions {
10    /// Skip the confirmation prompt (`-y/--yes`).
11    pub yes: bool,
12    /// Generate the message but do not commit or push.
13    pub dry_run: bool,
14    /// Commit locally without pushing.
15    pub no_push: bool,
16    /// Branch to push to (defaults to the current branch).
17    pub branch: Option<String>,
18    /// Open the generated message in `$EDITOR` before committing.
19    pub edit: bool,
20}
21
22pub struct Workflow {
23    llm: Llm,
24    #[allow(dead_code)]
25    config: Config,
26}
27
28impl Workflow {
29    pub fn new(config: Config) -> Self {
30        let llm = Llm::new(config.clone());
31        Self { llm, config }
32    }
33
34    /// Full workflow: stage → generate → confirm → commit → push.
35    /// This is the default `ghsi` behavior (the old `gp` alias).
36    pub async fn commit_workflow(&self, files: Vec<String>, opts: WorkflowOptions) -> Result<()> {
37        Git::ensure_repo()?;
38        println!("🔄 Starting automated commit workflow...");
39
40        self.stage_files(&files).await?;
41
42        if !Git::has_staged_changes()? {
43            return Err(SiError::NoChanges);
44        }
45
46        let (subject, body) = self.generate_for_staged().await?;
47
48        let (subject, body) = if opts.edit {
49            self.edit_message(subject, body)?
50        } else {
51            (subject, body)
52        };
53
54        Self::print_generated(&subject, &body);
55
56        if opts.dry_run {
57            println!("\n🏜️  Dry run — no commit created.");
58            Self::print_manual_command(&subject, &body);
59            return Ok(());
60        }
61
62        if !opts.yes && !Self::confirm_commit()? {
63            println!("❌ Commit cancelled by user");
64            return Ok(());
65        }
66
67        println!("💾 Creating commit...");
68        Git::commit(&subject, body.as_deref())?;
69
70        if opts.no_push {
71            println!("✅ Committed locally (--no-push). Push when ready: `git push`");
72            return Ok(());
73        }
74
75        println!("🚀 Pushing to remote...");
76        let target_branch = match opts.branch.clone() {
77            Some(b) => b,
78            None => Git::get_current_branch()?,
79        };
80        println!("Pushing to branch: {target_branch}");
81        Git::push(Some(&target_branch))?;
82
83        println!("✅ Workflow completed successfully!");
84        Ok(())
85    }
86
87    /// Generate a message for already-staged changes. Does not commit.
88    pub async fn generate_message_only(&self) -> Result<()> {
89        Git::ensure_repo()?;
90        if !Git::has_staged_changes()? {
91            // Match the old tool's helpful behavior: stage everything
92            // rather than failing when the user forgot `git add`.
93            if Git::has_unstaged_changes()? {
94                println!("📁 Auto-staging all modified files...");
95                Git::add_all_changes()?;
96            } else {
97                return Err(SiError::NoChanges);
98            }
99        }
100
101        let (subject, body) = self.generate_for_staged().await?;
102        Self::print_generated(&subject, &body);
103        println!("\nTo use this message:");
104        Self::print_manual_command(&subject, &body);
105        Ok(())
106    }
107
108    /// Stage files (or everything), then generate. Does not commit.
109    /// This is the old `gs` alias (`ghit --stage-and-generate`).
110    pub async fn stage_and_generate(&self, files: Vec<String>) -> Result<()> {
111        Git::ensure_repo()?;
112        self.stage_files(&files).await?;
113        // Reuse generate path without double-staging surprises:
114        // generate_for_staged only reads the staged diff.
115        let (subject, body) = self.generate_for_staged().await?;
116        Self::print_generated(&subject, &body);
117        println!("\nTo use this message:");
118        Self::print_manual_command(&subject, &body);
119        Ok(())
120    }
121
122    async fn stage_files(&self, files: &[String]) -> Result<()> {
123        if !files.is_empty() {
124            println!("📁 Adding specified files to staging area...");
125            Git::add_files(files)?;
126        } else if Git::has_unstaged_changes()? {
127            println!("📁 Auto-staging all modified files...");
128            Git::add_all_changes()?;
129        }
130        Ok(())
131    }
132
133    async fn generate_for_staged(&self) -> Result<(String, Option<String>)> {
134        println!("📊 Reading staged changes...");
135        let diff = Git::get_staged_diff()?;
136        let stat = Git::get_staged_stat().unwrap_or_default();
137        println!(
138            "🤖 Generating commit message with AI ({})...",
139            self.llm.model()
140        );
141        self.llm
142            .gen_commit_message(&diff, &stat)
143            .await
144            .map_err(|e| match e {
145                SiError::Api { provider, message } => SiError::Api {
146                    provider,
147                    message: format!(
148                        "{message}\nHint: check your model with `ghsi config model` \
149                         (settings: {}). Deprecated model ids are the most common cause.",
150                        Config::settings_reference()
151                    ),
152                },
153                other => other,
154            })
155    }
156
157    fn print_generated(subject: &str, body: &Option<String>) {
158        println!("\n📝 Generated commit message:");
159        println!("Subject: {subject}");
160        if let Some(body_text) = body {
161            println!("Body:\n{body_text}");
162        }
163    }
164
165    fn print_manual_command(subject: &str, body: &Option<String>) {
166        let escaped_subject = subject.replace('"', "\\\"");
167        match body {
168            Some(body_text) => {
169                let escaped_body = body_text.replace('"', "\\\"");
170                println!("git commit -m \"{escaped_subject}\" -m \"{escaped_body}\"");
171            }
172            None => println!("git commit -m \"{escaped_subject}\""),
173        }
174    }
175
176    fn edit_message(
177        &self,
178        subject: String,
179        body: Option<String>,
180    ) -> Result<(String, Option<String>)> {
181        let initial = full_commit_text(&subject, &body);
182        let edited = edit_in_editor(&initial)?;
183        Ok(crate::prompt::parse_commit_message(&edited))
184    }
185
186    fn confirm_commit() -> Result<bool> {
187        print!("\n❓ Create this commit? [Y/n]: ");
188        io::stdout().flush()?;
189        let mut input = String::new();
190        io::stdin().read_line(&mut input)?;
191        let input = input.trim().to_lowercase();
192        Ok(input.is_empty() || input == "y" || input == "yes")
193    }
194}
195
196/// Open `text` in `$EDITOR` (falling back to `vi` / `notepad`) and
197/// return the edited contents.
198pub fn edit_in_editor(text: &str) -> Result<String> {
199    let editor = std::env::var("VISUAL")
200        .ok()
201        .filter(|v| !v.trim().is_empty())
202        .or_else(|| {
203            std::env::var("EDITOR")
204                .ok()
205                .filter(|v| !v.trim().is_empty())
206        })
207        .unwrap_or_else(|| {
208            if cfg!(windows) {
209                "notepad".to_string()
210            } else {
211                "vi".to_string()
212            }
213        });
214
215    let path = std::env::temp_dir().join(format!("ghsi-commit-{}.txt", std::process::id()));
216    std::fs::write(&path, text)?;
217
218    let status = std::process::Command::new(&editor)
219        .arg(&path)
220        .status()
221        .map_err(|e| {
222            SiError::Other(format!(
223                "failed to launch editor `{editor}`: {e}. Set $EDITOR and retry."
224            ))
225        })?;
226
227    if !status.success() {
228        let _ = std::fs::remove_file(&path);
229        return Err(SiError::Other(format!(
230            "editor `{editor}` exited with {status}"
231        )));
232    }
233
234    let edited = std::fs::read_to_string(&path)?;
235    let _ = std::fs::remove_file(&path);
236    Ok(edited)
237}