Skip to content

Commit ef932f5

Browse files
committed
feat: add auto-update functionality and update command
1 parent 8ff636f commit ef932f5

8 files changed

Lines changed: 394 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ cgen --no-verify # Forward flags to git commit
9292
cgen alter <hash> # Regenerate message from that commit's diff and rewrite it
9393
cgen alter <old> <new> # Use old..new net diff, rewrite <new> message
9494
cgen undo # Undo latest commit with safety prompts (soft reset)
95+
cgen update # Update cgen to the latest version
9596
cgen config # Interactive config editor (local .env)
9697
cgen config --global # Interactive config editor (global TOML)
9798
```
@@ -121,6 +122,7 @@ All settings use the `ACR_` prefix. Layered resolution: defaults → global TOML
121122
| `ACR_WARN_STAGED_FILES_ENABLED` | `1` | Warn when staged file count exceeds threshold (`1`/`0`) |
122123
| `ACR_WARN_STAGED_FILES_THRESHOLD` | `20` | Staged files warning threshold (warn when count is greater) |
123124
| `ACR_CONFIRM_NEW_VERSION` | `1` | Ask before creating the computed `--tag` version (`1`/`0`) |
125+
| `ACR_AUTO_UPDATE` || Enable automatic updates (`1`/`0`); prompts on first run if unset |
124126

125127
### Config Locations
126128

@@ -157,6 +159,17 @@ ACR_API_HEADERS=Authorization: Bearer $ACR_API_KEY, X-Custom: $MY_HEADER
157159
- For rewritten pushed history, cgen does not auto-force-push; use manual `git push --force-with-lease` if needed.
158160
- `cgen undo` only undoes the latest commit (`git reset --soft HEAD~1`), never pushes, and warns before undoing pushed commits.
159161

162+
### Updating
163+
164+
- `cgen update` checks for a newer version on GitHub and runs the appropriate installer:
165+
- If `cargo` is available: `cargo install auto-commit-rs`
166+
- Otherwise on Linux/macOS: re-runs the curl install script
167+
- Otherwise on Windows: re-runs the PowerShell install script
168+
- On every run, cgen checks the latest GitHub release tag against the current version.
169+
- The first time cgen runs, it asks whether to enable automatic updates and saves the preference to the global config.
170+
- If `ACR_AUTO_UPDATE=1`, cgen automatically updates when a newer version is found.
171+
- If `ACR_AUTO_UPDATE=0` (or unset after the prompt), a warning is shown at the end of the output with the available version.
172+
160173
## Providers
161174

162175
Built-in providers: **Groq** (default), **OpenAI**, **Anthropic**, **Gemini**.

src/cli.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub enum Command {
4949
#[arg(value_name = "HASH", num_args = 1..=2)]
5050
commits: Vec<String>,
5151
},
52+
/// Update cgen to the latest version
53+
Update,
5254
}
5355

5456
pub fn parse() -> Cli {
@@ -73,7 +75,7 @@ pub fn interactive_config(global: bool) -> Result<()> {
7375
all_options.push("Exit without saving".red().to_string());
7476

7577
let selection = Select::new("Edit a setting:", all_options)
76-
.with_page_size(17)
78+
.with_page_size(18)
7779
.prompt();
7880

7981
let selection = match selection {
@@ -181,6 +183,13 @@ pub fn interactive_config(global: bool) -> Result<()> {
181183
.ok()
182184
.map(|v| v.chars().next().unwrap().to_string())
183185
}
186+
"AUTO_UPDATE" => {
187+
let choices = vec!["1 (yes)", "0 (no)"];
188+
Select::new("Enable automatic updates:", choices)
189+
.prompt()
190+
.ok()
191+
.map(|v| v.chars().next().unwrap().to_string())
192+
}
184193
"API_KEY" => Text::new("API Key:")
185194
.with_help_message("Your LLM provider API key")
186195
.prompt()

src/config.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub struct AppConfig {
4343
pub warn_staged_files_threshold: usize,
4444
#[serde(default = "default_true")]
4545
pub confirm_new_version: bool,
46+
#[serde(default, skip_serializing_if = "Option::is_none")]
47+
pub auto_update: Option<bool>,
4648
}
4749

4850
fn default_provider() -> String {
@@ -93,6 +95,7 @@ impl Default for AppConfig {
9395
warn_staged_files_enabled: true,
9496
warn_staged_files_threshold: default_warn_staged_files_threshold(),
9597
confirm_new_version: true,
98+
auto_update: None,
9699
}
97100
}
98101
}
@@ -116,6 +119,7 @@ const ENV_FIELD_MAP: &[(&str, &str)] = &[
116119
("WARN_STAGED_FILES_ENABLED", "warn_staged_files_enabled"),
117120
("WARN_STAGED_FILES_THRESHOLD", "warn_staged_files_threshold"),
118121
("CONFIRM_NEW_VERSION", "confirm_new_version"),
122+
("AUTO_UPDATE", "auto_update"),
119123
];
120124

121125
impl AppConfig {
@@ -195,6 +199,9 @@ impl AppConfig {
195199
self.warn_staged_files_enabled = other.warn_staged_files_enabled;
196200
self.warn_staged_files_threshold = other.warn_staged_files_threshold;
197201
self.confirm_new_version = other.confirm_new_version;
202+
if other.auto_update.is_some() {
203+
self.auto_update = other.auto_update;
204+
}
198205
}
199206

200207
fn apply_env_map(&mut self, map: &HashMap<String, String>) {
@@ -233,6 +240,10 @@ impl AppConfig {
233240
"CONFIRM_NEW_VERSION" => {
234241
self.confirm_new_version = val == "1" || val.eq_ignore_ascii_case("true")
235242
}
243+
"AUTO_UPDATE" => {
244+
self.auto_update =
245+
Some(val == "1" || val.eq_ignore_ascii_case("true"));
246+
}
236247
_ => {}
237248
}
238249
}
@@ -313,6 +324,12 @@ impl AppConfig {
313324
"ACR_CONFIRM_NEW_VERSION={}",
314325
if self.confirm_new_version { "1" } else { "0" }
315326
));
327+
if let Some(auto_update) = self.auto_update {
328+
lines.push(format!(
329+
"ACR_AUTO_UPDATE={}",
330+
if auto_update { "1" } else { "0" }
331+
));
332+
}
316333

317334
std::fs::write(&env_path, lines.join("\n") + "\n")
318335
.with_context(|| format!("Failed to write {}", env_path.display()))?;
@@ -431,6 +448,15 @@ impl AppConfig {
431448
"0 (no)".into()
432449
},
433450
),
451+
(
452+
"Auto Update",
453+
"AUTO_UPDATE",
454+
match self.auto_update {
455+
Some(true) => "1 (yes)".into(),
456+
Some(false) => "0 (no)".into(),
457+
None => "(not set)".into(),
458+
},
459+
),
434460
]
435461
}
436462

@@ -469,6 +495,9 @@ impl AppConfig {
469495
"CONFIRM_NEW_VERSION" => {
470496
self.confirm_new_version = value == "1" || value.eq_ignore_ascii_case("true");
471497
}
498+
"AUTO_UPDATE" => {
499+
self.auto_update = Some(value == "1" || value.eq_ignore_ascii_case("true"));
500+
}
472501
_ => {}
473502
}
474503
Ok(())
@@ -491,6 +520,31 @@ pub fn global_config_path() -> Option<PathBuf> {
491520
dirs::config_dir().map(|d| d.join("cgen").join("config.toml"))
492521
}
493522

523+
/// Save only the auto_update preference to global config without overwriting other fields
524+
pub fn save_auto_update_preference(value: bool) -> Result<()> {
525+
let path = global_config_path().context("Could not determine global config directory")?;
526+
527+
let mut table: toml::Table = if path.exists() {
528+
let content = std::fs::read_to_string(&path)
529+
.with_context(|| format!("Failed to read {}", path.display()))?;
530+
content.parse().unwrap_or_default()
531+
} else {
532+
toml::Table::new()
533+
};
534+
535+
table.insert("auto_update".to_string(), toml::Value::Boolean(value));
536+
537+
if let Some(parent) = path.parent() {
538+
std::fs::create_dir_all(parent)
539+
.with_context(|| format!("Failed to create {}", parent.display()))?;
540+
}
541+
542+
let content = toml::to_string_pretty(&table).context("Failed to serialize config")?;
543+
std::fs::write(&path, content)
544+
.with_context(|| format!("Failed to write {}", path.display()))?;
545+
Ok(())
546+
}
547+
494548
fn mask_key(key: &str) -> String {
495549
if key.len() <= 8 {
496550
"*".repeat(key.len())

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod git;
44
pub mod interpolation;
55
pub mod prompt;
66
pub mod provider;
7+
pub mod update;

src/main.rs

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use anyhow::{Context, Result};
2-
use auto_commit_rs::{cli, config, git, prompt, provider};
2+
use auto_commit_rs::{cli, config, git, prompt, provider, update};
33
use colored::Colorize;
44
use inquire::{Confirm, Select, Text};
55

@@ -13,14 +13,30 @@ fn main() {
1313
fn run() -> Result<()> {
1414
let cli = cli::parse();
1515
let cfg = match &cli.command {
16-
Some(cli::Command::Config { .. }) => None,
16+
Some(cli::Command::Config { .. }) | Some(cli::Command::Update) => None,
1717
_ => Some(config::AppConfig::load()?),
1818
};
1919

20+
// On first run, ask about auto-update preference
21+
if let Some(ref c) = cfg {
22+
if c.auto_update.is_none() {
23+
prompt_auto_update();
24+
}
25+
}
26+
27+
// Check for updates (except for config/update commands)
28+
let update_warning = match &cli.command {
29+
Some(cli::Command::Config { .. }) | Some(cli::Command::Update) => None,
30+
_ => check_for_updates(cfg.as_ref()),
31+
};
32+
2033
match &cli.command {
2134
Some(cli::Command::Config { global }) => {
2235
cli::interactive_config(*global)?;
2336
}
37+
Some(cli::Command::Update) => {
38+
run_update_command()?;
39+
}
2440
Some(cli::Command::Undo) => {
2541
run_undo(cfg.as_ref().expect("config should be loaded"))?;
2642
}
@@ -36,6 +52,11 @@ fn run() -> Result<()> {
3652
}
3753
}
3854

55+
// Show update warning at the end so it doesn't get buried
56+
if let Some(latest) = update_warning {
57+
update::print_update_warning(&latest);
58+
}
59+
3960
Ok(())
4061
}
4162

@@ -301,6 +322,101 @@ fn handle_post_commit_push(cfg: &config::AppConfig, ask_prompt: &str) -> Result<
301322
Ok(())
302323
}
303324

325+
fn prompt_auto_update() {
326+
let answer = Confirm::new("Would you like to enable automatic updates for cgen?")
327+
.with_default(true)
328+
.with_help_message("You can change this later with `cgen config --global`")
329+
.prompt();
330+
331+
match answer {
332+
Ok(yes) => {
333+
if let Err(e) = config::save_auto_update_preference(yes) {
334+
eprintln!(
335+
"{} Failed to save auto-update preference: {}",
336+
"warning:".yellow().bold(),
337+
e
338+
);
339+
} else {
340+
let status = if yes { "enabled" } else { "disabled" };
341+
println!(
342+
"{} Auto-updates {}.\n",
343+
"done!".green().bold(),
344+
status
345+
);
346+
}
347+
}
348+
Err(_) => {
349+
// User cancelled - leave as None, will ask again next time
350+
}
351+
}
352+
}
353+
354+
/// Check for updates and either auto-update or return the latest version for a warning.
355+
/// Returns Some(latest_version) if a warning should be shown, None otherwise.
356+
fn check_for_updates(cfg: Option<&config::AppConfig>) -> Option<String> {
357+
let version_check = match update::check_version() {
358+
Ok(v) => v,
359+
Err(_) => return None, // silently ignore network errors
360+
};
361+
362+
if !version_check.update_available {
363+
return None;
364+
}
365+
366+
let auto_update = cfg.and_then(|c| c.auto_update).unwrap_or(false);
367+
368+
if auto_update {
369+
println!(
370+
"{} {} → {}",
371+
"Auto-updating cgen...".cyan().bold(),
372+
version_check.current.dimmed(),
373+
version_check.latest.green(),
374+
);
375+
if let Err(e) = update::run_update() {
376+
eprintln!(
377+
"{} Auto-update failed: {}",
378+
"warning:".yellow().bold(),
379+
e
380+
);
381+
return Some(version_check.latest);
382+
}
383+
println!(
384+
"{} Restart cgen to use the new version.\n",
385+
"note:".yellow().bold()
386+
);
387+
return None;
388+
}
389+
390+
Some(version_check.latest)
391+
}
392+
393+
fn run_update_command() -> Result<()> {
394+
println!("{}", "Checking for updates...".cyan().bold());
395+
396+
match update::check_version() {
397+
Ok(v) if v.update_available => {
398+
println!(
399+
"{} {} → {}",
400+
"New version available!".green().bold(),
401+
v.current.dimmed(),
402+
v.latest.green(),
403+
);
404+
update::run_update()?;
405+
}
406+
Ok(v) => {
407+
println!(
408+
"{} You are already on the latest version ({}).",
409+
"Up to date!".green().bold(),
410+
v.current,
411+
);
412+
}
413+
Err(e) => {
414+
anyhow::bail!("Failed to check for updates: {}", e);
415+
}
416+
}
417+
Ok(())
418+
}
419+
304420
fn run_undo(cfg: &config::AppConfig) -> Result<()> {
305421
git::ensure_head_exists()?;
306422

0 commit comments

Comments
 (0)