si_git_commit/
workflow.rs1use 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 pub yes: bool,
12 pub dry_run: bool,
14 pub no_push: bool,
16 pub branch: Option<String>,
18 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 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 pub async fn generate_message_only(&self) -> Result<()> {
89 Git::ensure_repo()?;
90 if !Git::has_staged_changes()? {
91 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 pub async fn stage_and_generate(&self, files: Vec<String>) -> Result<()> {
111 Git::ensure_repo()?;
112 self.stage_files(&files).await?;
113 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
196pub 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}