Skip to content

Commit 42aa9f4

Browse files
JheisonMBclaude
andcommitted
refactor: improve build name validation, hook detection, and Windows compatibility
- Add Windows USERPROFILE fallback for HOME environment variable - Enforce build name validation (no paths, no empty names) - Refactor hook detection to separate builtins from custom hooks - Extract custom hook commands from script shebangs - Simplify struct initialization with default patterns - Remove unnecessary dead_code annotations Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 21fb39f commit 42aa9f4

4 files changed

Lines changed: 135 additions & 81 deletions

File tree

src/builds/mod.rs

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,17 @@ fn default_scope() -> String {
9292
}
9393

9494
pub(crate) fn builds_dir() -> Result<PathBuf> {
95-
let home = std::env::var("HOME").context("HOME environment variable not set")?;
95+
let home = std::env::var("HOME")
96+
.or_else(|_| std::env::var("USERPROFILE"))
97+
.context("Neither HOME nor USERPROFILE environment variable is set")?;
9698
Ok(PathBuf::from(home).join(".gitkit").join("builds"))
9799
}
98100

99101
fn build_path(name: &str) -> Result<PathBuf> {
102+
anyhow::ensure!(
103+
!name.is_empty() && !name.contains(['/', '\\']) && name != "." && name != "..",
104+
"Invalid build name '{name}' — use a simple name without path separators"
105+
);
100106
Ok(builds_dir()?.join(format!("{name}.toml")))
101107
}
102108

@@ -256,7 +262,7 @@ pub(crate) fn apply_build(build: &Build) -> Result<()> {
256262
Ok(())
257263
}
258264

259-
fn save(name: &str, description: Option<&str>) -> Result<()> {
265+
pub(crate) fn save(name: &str, description: Option<&str>) -> Result<()> {
260266
let path = build_path(name)?;
261267
if path.exists() {
262268
anyhow::bail!("Build '{name}' already exists. Delete it first or choose another name.");
@@ -278,21 +284,29 @@ pub(crate) fn capture_current_config(name: &str, description: Option<&str>) -> R
278284
let root = crate::utils::find_repo_root()?;
279285

280286
let mut builtins = Vec::new();
287+
let mut custom = Vec::new();
281288
let hooks_dir = root.join(".git").join("hooks");
282289
if hooks_dir.exists() {
283290
if let Ok(entries) = fs::read_dir(&hooks_dir) {
284291
for entry in entries.filter_map(|e| e.ok()) {
292+
let path = entry.path();
293+
if !path.is_file() {
294+
continue;
295+
}
285296
let hook_name = entry.file_name().to_string_lossy().to_string();
286297
if hook_name.ends_with(".bak") || hook_name.ends_with(".sample") {
287298
continue;
288299
}
289-
let content = fs::read_to_string(entry.path()).unwrap_or_default();
290-
let prefix: String = content.chars().take(80).collect();
291-
if let Some(b) = crate::hooks::available_builtins()
292-
.iter()
293-
.find(|b| b.hook == hook_name && content.contains(&prefix))
294-
{
300+
let content = fs::read_to_string(&path).unwrap_or_default();
301+
if let Some(b) = crate::hooks::detect_builtin(&hook_name, &content) {
295302
builtins.push(b.name.to_string());
303+
} else if crate::hooks::valid_hook_names().contains(&hook_name.as_str()) {
304+
if let Some(command) = extract_custom_command(&content) {
305+
custom.push(CustomHook {
306+
hook: hook_name,
307+
command,
308+
});
309+
}
296310
}
297311
}
298312
}
@@ -329,10 +343,7 @@ pub(crate) fn capture_current_config(name: &str, description: Option<&str>) -> R
329343
Ok(Build {
330344
name: name.to_string(),
331345
description: description.unwrap_or("").to_string(),
332-
hooks: HooksConfig {
333-
builtins,
334-
custom: Vec::new(),
335-
},
346+
hooks: HooksConfig { builtins, custom },
336347
gitignore: GitignoreConfig { templates },
337348
gitattributes: GitattributesConfig { presets },
338349
config: ConfigBuild {
@@ -342,6 +353,23 @@ pub(crate) fn capture_current_config(name: &str, description: Option<&str>) -> R
342353
})
343354
}
344355

356+
/// Recovers the command from a custom hook script (shebang + `set -e` + command).
357+
fn extract_custom_command(content: &str) -> Option<String> {
358+
let lines: Vec<&str> = content
359+
.lines()
360+
.map(str::trim_end)
361+
.filter(|l| {
362+
let t = l.trim();
363+
!t.is_empty() && !t.starts_with('#') && t != "set -e"
364+
})
365+
.collect();
366+
if lines.is_empty() {
367+
None
368+
} else {
369+
Some(lines.join("\n"))
370+
}
371+
}
372+
345373
fn detect_gitignore_templates(content: &str) -> Vec<String> {
346374
let mut templates = Vec::new();
347375

@@ -381,7 +409,6 @@ fn delete(name: &str) -> Result<()> {
381409
Ok(())
382410
}
383411

384-
#[allow(dead_code)]
385412
pub(crate) fn list_build_names() -> Vec<String> {
386413
builds_dir()
387414
.ok()
@@ -401,7 +428,6 @@ pub(crate) fn list_build_names() -> Vec<String> {
401428
.unwrap_or_default()
402429
}
403430

404-
#[allow(dead_code)]
405431
pub(crate) fn load_build(name: &str) -> Result<Build> {
406432
let path = build_path(name)?;
407433
anyhow::ensure!(path.exists(), "Build '{name}' not found");
@@ -469,4 +495,23 @@ description = ""
469495
let presets = detect_gitattributes_presets(content);
470496
assert!(presets.contains(&"line-endings".to_string()));
471497
}
498+
499+
#[test]
500+
fn extract_custom_command_recovers_command() {
501+
let script = "#!/bin/sh\nset -e\ncargo test\n";
502+
assert_eq!(extract_custom_command(script).as_deref(), Some("cargo test"));
503+
}
504+
505+
#[test]
506+
fn extract_custom_command_returns_none_for_empty_script() {
507+
assert!(extract_custom_command("#!/bin/sh\nset -e\n").is_none());
508+
}
509+
510+
#[test]
511+
fn build_path_rejects_invalid_names() {
512+
assert!(build_path("").is_err());
513+
assert!(build_path("../evil").is_err());
514+
assert!(build_path("a/b").is_err());
515+
assert!(build_path("ok-name").is_ok());
516+
}
472517
}

src/hooks/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ pub(crate) fn valid_hook_names() -> &'static [&'static str] {
8181
VALID_HOOKS
8282
}
8383

84+
/// Identifies which built-in (if any) an installed hook file corresponds to,
85+
/// by exact script comparison. Built-ins are written verbatim on install.
86+
pub(crate) fn detect_builtin(
87+
hook_file: &str,
88+
content: &str,
89+
) -> Option<&'static builtins::Builtin> {
90+
builtins::ALL
91+
.iter()
92+
.find(|b| b.hook == hook_file && content.trim() == b.script.trim())
93+
}
94+
8495
fn hooks_dir() -> Result<std::path::PathBuf> {
8596
Ok(find_repo_root()?.join(".git").join("hooks"))
8697
}
@@ -294,4 +305,25 @@ mod tests {
294305
let err = resolve_hook("unknown-builtin", None).unwrap_err();
295306
assert!(err.to_string().contains("not a built-in"));
296307
}
308+
309+
#[test]
310+
fn detect_builtin_distinguishes_builtins_sharing_a_hook_file() {
311+
let no_secrets = builtins::get("no-secrets").unwrap();
312+
let branch_naming = builtins::get("branch-naming").unwrap();
313+
assert_eq!(
314+
detect_builtin("pre-commit", no_secrets.script).map(|b| b.name),
315+
Some("no-secrets")
316+
);
317+
assert_eq!(
318+
detect_builtin("pre-commit", branch_naming.script).map(|b| b.name),
319+
Some("branch-naming")
320+
);
321+
}
322+
323+
#[test]
324+
fn detect_builtin_rejects_custom_scripts_and_wrong_hook() {
325+
assert!(detect_builtin("pre-commit", "#!/bin/sh\nset -e\ncargo test\n").is_none());
326+
let no_secrets = builtins::get("no-secrets").unwrap();
327+
assert!(detect_builtin("commit-msg", no_secrets.script).is_none());
328+
}
297329
}

src/init.rs

Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,10 @@ pub fn run() -> Result<()> {
3535
options.extend(saved_builds.iter().map(|b| format!("Use build: {b}")));
3636

3737
let choice = Select::new("Saved builds available", options)
38-
.with_help_message("↑↓ move enter confirm")
39-
.prompt_skippable()?
40-
.unwrap_or_default();
38+
.with_help_message("↑↓ move enter confirm esc start fresh")
39+
.prompt_skippable()?;
4140

42-
if choice != "Start fresh configuration" {
43-
let build_name = choice.strip_prefix("Use build: ").unwrap_or(&choice);
41+
if let Some(build_name) = choice.as_deref().and_then(|c| c.strip_prefix("Use build: ")) {
4442
println!();
4543
let build = builds::load_build(build_name)?;
4644
builds::apply_build(&build)?;
@@ -97,10 +95,17 @@ pub fn run() -> Result<()> {
9795

9896
for item in &hook_selections {
9997
if item == "Add custom hook..." {
100-
let hook_name = Select::new("Hook type", hooks::valid_hook_names().to_vec())
101-
.prompt()
102-
.map_err(|e| anyhow::anyhow!("Hook selection cancelled: {}", e))?;
103-
let command = Text::new(" Command to run").prompt()?;
98+
let Some(hook_name) = Select::new("Hook type", hooks::valid_hook_names().to_vec())
99+
.prompt_skippable()?
100+
else {
101+
continue;
102+
};
103+
let command = Text::new(" Command to run")
104+
.prompt_skippable()?
105+
.unwrap_or_default();
106+
if command.trim().is_empty() {
107+
continue;
108+
}
104109
custom_hooks.push((hook_name.to_string(), command));
105110
} else if let Some(idx) = hook_items.iter().position(|i| i == item) {
106111
if idx < builtins.len() {
@@ -170,7 +175,7 @@ pub fn run() -> Result<()> {
170175
let defaults: Vec<usize> = config_options
171176
.iter()
172177
.enumerate()
173-
.filter(|(_, o)| o.recommended)
178+
.filter(|(_, o)| o.recommended || configured_keys.contains(o.key))
174179
.map(|(i, _)| i)
175180
.collect();
176181

@@ -247,6 +252,16 @@ pub fn run() -> Result<()> {
247252
}
248253
for hook in &hooks_to_remove {
249254
if let Some(builtin) = hooks::available_builtins().iter().find(|b| b.name == *hook) {
255+
// Several built-ins can share a hook file (e.g. pre-commit); don't
256+
// delete the file if a freshly installed selection now owns it.
257+
let file_reused = selected_builtins.iter().any(|sel| {
258+
hooks::available_builtins()
259+
.iter()
260+
.any(|b| b.name == *sel && b.hook == builtin.hook)
261+
});
262+
if file_reused {
263+
continue;
264+
}
250265
if hooks::remove_hook(builtin.hook, true).is_ok() {
251266
println!(" ◇ hook '{hook}' removed ✓");
252267
}
@@ -269,20 +284,19 @@ pub fn run() -> Result<()> {
269284
)?;
270285
println!(" ◇ git config applied ✓");
271286
}
287+
// Only touch the repo's local config; a global value affects every repo,
288+
// so it is never removed from here.
272289
for key in &configs_to_remove {
273-
let removed = config::remove_config_key(key, config::ConfigScope::Local).is_ok()
274-
|| config::remove_config_key(key, config::ConfigScope::Global).is_ok();
275-
if removed {
290+
if config::remove_config_key(key, config::ConfigScope::Local).is_ok() {
276291
println!(" ◇ git config '{key}' removed ✓");
292+
} else {
293+
println!(
294+
" ◇ git config '{key}' is set globally — left untouched (git config --global --unset {key})"
295+
);
277296
}
278297
}
279298

280299
// ── Save as build ─────────────────────────────────────────────────────
281-
if nothing {
282-
println!("\n Nothing selected — exiting.");
283-
return Ok(());
284-
}
285-
286300
println!();
287301
let save_build = inquire::Confirm::new("Save this configuration as a reusable build?")
288302
.with_default(false)
@@ -298,19 +312,9 @@ pub fn run() -> Result<()> {
298312
} else {
299313
Some(description.as_str())
300314
};
301-
builds::capture_current_config(&name, desc_ref)
302-
.and_then(|b| {
303-
let dir = builds::builds_dir()?;
304-
fs::create_dir_all(&dir)?;
305-
let path = dir.join(format!("{name}.toml"));
306-
let content = toml::to_string_pretty(&b)?;
307-
fs::write(&path, content)?;
308-
println!(" ✓ Build '{name}' saved");
309-
Ok(())
310-
})
311-
.unwrap_or_else(|e| {
312-
println!(" ⚠ Failed to save build: {e}");
313-
});
315+
if let Err(e) = builds::save(&name, desc_ref) {
316+
println!(" ⚠ Failed to save build: {e}");
317+
}
314318
}
315319

316320
println!("\n Done\n");
@@ -331,16 +335,7 @@ fn get_installed_hooks() -> HashSet<String> {
331335
let name = entry.file_name().to_string_lossy().to_string();
332336
if !name.ends_with(".bak") && !name.ends_with(".sample") {
333337
let content = fs::read_to_string(entry.path()).unwrap_or_default();
334-
let builtin_match = hooks::available_builtins().iter().find(|b| {
335-
b.hook == name
336-
&& b.script
337-
.chars()
338-
.take(80)
339-
.collect::<String>()
340-
.chars()
341-
.all(|c| content.contains(c))
342-
});
343-
if let Some(b) = builtin_match {
338+
if let Some(b) = hooks::detect_builtin(&name, &content) {
344339
installed.insert(b.name.to_string());
345340
}
346341
}
@@ -416,10 +411,11 @@ mod tests {
416411
use super::*;
417412

418413
#[test]
419-
fn get_configured_keys_works() {
414+
fn get_configured_keys_only_returns_known_option_keys() {
420415
let configured = get_configured_keys();
421-
// Just verify it doesn't panic and returns a valid HashSet
422-
assert!(configured.len() >= 0);
416+
for key in &configured {
417+
assert!(config::CONFIG_OPTIONS.iter().any(|o| o.key == key));
418+
}
423419
}
424420

425421
#[test]

src/status/mod.rs

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use anyhow::Result;
22
use std::fs;
33

4-
use crate::hooks::builtins;
54
use crate::utils::{find_repo_root, git_config_get};
65

76
pub fn run() -> Result<()> {
@@ -52,17 +51,7 @@ fn print_hooks() -> Result<()> {
5251
let hook_name = entry.file_name().to_string_lossy().to_string();
5352
let content = fs::read_to_string(entry.path()).unwrap_or_default();
5453

55-
let builtin_match = builtins::ALL.iter().find(|b| {
56-
b.hook == hook_name
57-
&& b.script
58-
.chars()
59-
.take(80)
60-
.collect::<String>()
61-
.chars()
62-
.all(|c| content.contains(c))
63-
});
64-
65-
match builtin_match {
54+
match crate::hooks::detect_builtin(&hook_name, &content) {
6655
Some(b) => println!(" ✓ {} ({})", b.name, b.hook),
6756
None => {
6857
let first_cmd = content
@@ -143,18 +132,10 @@ fn print_config(scope: &str) -> Result<()> {
143132
"--local"
144133
};
145134

146-
let configs = [
147-
("push.autoSetupRemote", "true"),
148-
("help.autocorrect", "prompt"),
149-
("diff.algorithm", "histogram"),
150-
("merge.conflictstyle", "zdiff3"),
151-
("rerere.enabled", "true"),
152-
];
153-
154135
let mut any = false;
155-
for (key, _expected) in &configs {
156-
if let Some(value) = git_config_get(key, scope_flag) {
157-
println!(" ✓ {key} = {value}");
136+
for option in crate::config::CONFIG_OPTIONS {
137+
if let Some(value) = git_config_get(option.key, scope_flag) {
138+
println!(" ✓ {} = {value}", option.key);
158139
any = true;
159140
}
160141
}

0 commit comments

Comments
 (0)