Skip to content

Commit 8e6425f

Browse files
committed
feat: wizard shows current state and allows removal
- Detect installed hooks and mark with [✓ installed] - Pre-select already installed hooks in wizard - Detect configured git config and mark with [✓ already set] - Allow deselecting to remove hooks and configs - Add remove_hook and remove_config_key helper functions
1 parent 1396bc8 commit 8e6425f

3 files changed

Lines changed: 140 additions & 20 deletions

File tree

src/config/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,16 @@ fn git_config_set(key: &str, value: &str, scope: ConfigScope) -> Result<()> {
289289
Ok(())
290290
}
291291

292+
pub(crate) fn remove_config_key(key: &str, scope: ConfigScope) -> Result<()> {
293+
let flag = scope_flag(scope);
294+
let status = Command::new("git")
295+
.args(["config", flag, "--unset", key])
296+
.status()
297+
.with_context(|| format!("Failed to unset git config for '{key}'"))?;
298+
anyhow::ensure!(status.success(), "git config {flag} --unset {key} failed");
299+
Ok(())
300+
}
301+
292302
fn delta_installed() -> bool {
293303
Command::new("delta")
294304
.arg("--version")

src/hooks/mod.rs

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -211,22 +211,14 @@ fn list(available: bool) -> Result<()> {
211211
Ok(())
212212
}
213213

214-
fn remove(hook: &str, yes: bool, dry_run: bool) -> Result<()> {
214+
fn remove(hook: &str, yes: bool, _dry_run: bool) -> Result<()> {
215+
remove_hook(hook, yes)
216+
}
217+
218+
pub(crate) fn remove_hook(hook: &str, _yes: bool) -> Result<()> {
215219
let path = hooks_dir()?.join(hook);
216220
anyhow::ensure!(path.exists(), "Hook '{hook}' is not installed");
217-
218-
if !confirm(&format!("Remove hook '{hook}'?"), yes) {
219-
println!("Aborted.");
220-
return Ok(());
221-
}
222-
223-
if dry_run {
224-
println!("[dry-run] Would remove hook '{hook}'.");
225-
return Ok(());
226-
}
227-
228221
fs::remove_file(&path).with_context(|| format!("Failed to remove hook '{hook}'"))?;
229-
println!("Removed hook '{hook}'.");
230222
Ok(())
231223
}
232224

src/init.rs

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use anyhow::Result;
22
use inquire::{MultiSelect, Text};
3+
use std::{collections::HashSet, fs};
34

4-
use crate::{attributes, config, git, hooks, ignore};
5+
use crate::{attributes, config, git, hooks, ignore, utils::find_repo_root};
56

67
const BANNER: &str = r#"
78
███ █████ █████ ███ █████
@@ -35,14 +36,36 @@ pub fn run() -> Result<()> {
3536

3637
// ── Hooks ────────────────────────────────────────────────────────────────
3738
let builtins = hooks::available_builtins();
39+
let installed_hooks = get_installed_hooks();
40+
3841
let mut hook_items: Vec<String> = builtins
3942
.iter()
40-
.map(|b| format!("{:<25} ({}) — {}", b.name, b.hook, b.description))
43+
.map(|b| {
44+
let base = format!("{:<25} ({}) — {}", b.name, b.hook, b.description);
45+
if installed_hooks.contains(b.name) {
46+
format!("{} [✓ installed]", base)
47+
} else {
48+
base
49+
}
50+
})
4151
.collect();
4252
hook_items.push("Add custom hook...".to_string());
4353

54+
let preselected: Vec<usize> = builtins
55+
.iter()
56+
.enumerate()
57+
.filter(|(_, b)| installed_hooks.contains(b.name))
58+
.map(|(i, _)| i)
59+
.collect();
60+
61+
let default_selection = if preselected.is_empty() {
62+
vec![0usize]
63+
} else {
64+
preselected
65+
};
66+
4467
let hook_selections = MultiSelect::new("Hooks", hook_items.clone())
45-
.with_default(&[0usize]) // conventional-commits preselected
68+
.with_default(&default_selection)
4669
.with_help_message("↑↓ move space select enter confirm esc skip")
4770
.prompt_skippable()?
4871
.unwrap_or_default();
@@ -65,6 +88,12 @@ pub fn run() -> Result<()> {
6588
}
6689
}
6790

91+
let hooks_to_remove: Vec<&str> = installed_hooks
92+
.iter()
93+
.filter(|h| !selected_builtins.contains(&h.as_str()))
94+
.map(|s| s.as_str())
95+
.collect();
96+
6897
// ── .gitignore ───────────────────────────────────────────────────────────
6998
println!();
7099
let all_templates = load_ignore_templates();
@@ -97,33 +126,51 @@ pub fn run() -> Result<()> {
97126

98127
// ── Git config ───────────────────────────────────────────────────────────
99128
println!();
129+
let configured_keys = get_configured_keys();
130+
100131
let config_options: Vec<&config::ConfigOption> = config::CONFIG_OPTIONS
101132
.iter()
102133
.filter(|o| o.key != "core.pager" || cargo_available)
103134
.collect();
104135

105-
let config_labels: Vec<&str> = config_options.iter().map(|o| o.label).collect();
136+
let config_labels: Vec<String> = config_options
137+
.iter()
138+
.map(|o| {
139+
if configured_keys.contains(o.key) {
140+
format!("{} [✓ already set]", o.label)
141+
} else {
142+
o.label.to_string()
143+
}
144+
})
145+
.collect();
146+
147+
let config_labels_refs: Vec<&str> = config_labels.iter().map(|s| s.as_str()).collect();
106148

107-
// pre-select recommended ones
108149
let defaults: Vec<usize> = config_options
109150
.iter()
110151
.enumerate()
111152
.filter(|(_, o)| o.recommended)
112153
.map(|(i, _)| i)
113154
.collect();
114155

115-
let config_selections = MultiSelect::new("Git config", config_labels.clone())
156+
let config_selections = MultiSelect::new("Git config", config_labels_refs.clone())
116157
.with_default(&defaults)
117158
.with_help_message("↑↓ move space select enter confirm esc skip")
118159
.prompt_skippable()?
119160
.unwrap_or_default();
120161

121162
let selected_config_keys: Vec<&str> = resolve_keys(
122163
&config_selections,
123-
&config_labels,
164+
&config_labels_refs,
124165
&config_options.iter().map(|o| o.key).collect::<Vec<_>>(),
125166
);
126167

168+
let configs_to_remove: Vec<&str> = config_options
169+
.iter()
170+
.filter(|o| configured_keys.contains(o.key) && !selected_config_keys.contains(&o.key))
171+
.map(|o| o.key)
172+
.collect();
173+
127174
// ── Summary & confirm ────────────────────────────────────────────────────
128175
let nothing = selected_builtins.is_empty()
129176
&& custom_hooks.is_empty()
@@ -175,6 +222,13 @@ pub fn run() -> Result<()> {
175222
hooks::install_custom(hook, cmd, false)?;
176223
println!(" ◇ hook '{hook}' installed ✓");
177224
}
225+
for hook in &hooks_to_remove {
226+
if let Some(builtin) = hooks::available_builtins().iter().find(|b| b.name == *hook) {
227+
if hooks::remove_hook(builtin.hook, true).is_ok() {
228+
println!(" ◇ hook '{hook}' removed ✓");
229+
}
230+
}
231+
}
178232
if !selected_templates.is_empty() {
179233
let joined = selected_templates.join(",");
180234
ignore::add_templates(&joined, false)?;
@@ -192,6 +246,12 @@ pub fn run() -> Result<()> {
192246
)?;
193247
println!(" ◇ git config applied ✓");
194248
}
249+
for key in &configs_to_remove {
250+
if config::remove_config_key(key, config::ConfigScope::Local).is_err() {
251+
let _ = config::remove_config_key(key, config::ConfigScope::Global);
252+
}
253+
println!(" ◇ git config '{key}' removed ✓");
254+
}
195255

196256
println!("\n Done\n");
197257
Ok(())
@@ -201,6 +261,64 @@ fn load_ignore_templates() -> Vec<String> {
201261
ignore::fetch_template_list().unwrap_or_default()
202262
}
203263

264+
fn get_installed_hooks() -> HashSet<String> {
265+
let mut installed = HashSet::new();
266+
if let Ok(root) = find_repo_root() {
267+
let hooks_dir = root.join(".git").join("hooks");
268+
if hooks_dir.exists() {
269+
if let Ok(entries) = fs::read_dir(&hooks_dir) {
270+
for entry in entries.filter_map(|e| e.ok()) {
271+
let name = entry.file_name().to_string_lossy().to_string();
272+
if !name.ends_with(".bak") && !name.ends_with(".sample") {
273+
let content = fs::read_to_string(entry.path()).unwrap_or_default();
274+
let builtin_match = hooks::available_builtins().iter().find(|b| {
275+
b.hook == name && content.contains(&b.script[..80.min(b.script.len())])
276+
});
277+
if let Some(b) = builtin_match {
278+
installed.insert(b.name.to_string());
279+
}
280+
}
281+
}
282+
}
283+
}
284+
}
285+
installed
286+
}
287+
288+
fn get_configured_keys() -> HashSet<String> {
289+
let mut configured = HashSet::new();
290+
for option in config::CONFIG_OPTIONS {
291+
if option.key == "core.pager" {
292+
continue;
293+
}
294+
if let Some(value) = option.value {
295+
if let Ok(output) = std::process::Command::new("git")
296+
.args(["config", "--local", "--get", option.key])
297+
.output()
298+
{
299+
if output.status.success() {
300+
let current = String::from_utf8_lossy(&output.stdout).trim().to_string();
301+
if current == value {
302+
configured.insert(option.key.to_string());
303+
}
304+
}
305+
}
306+
if let Ok(output) = std::process::Command::new("git")
307+
.args(["config", "--global", "--get", option.key])
308+
.output()
309+
{
310+
if output.status.success() {
311+
let current = String::from_utf8_lossy(&output.stdout).trim().to_string();
312+
if current == value {
313+
configured.insert(option.key.to_string());
314+
}
315+
}
316+
}
317+
}
318+
}
319+
configured
320+
}
321+
204322
/// Maps selected display labels back to their corresponding keys.
205323
fn resolve_keys<'a>(
206324
selections: &[impl AsRef<str>],

0 commit comments

Comments
 (0)