Skip to content

Commit 1396bc8

Browse files
committed
feat: add config scope support and idempotency detection
- Add --global/--local flags to config apply - Default scope: --local if in repo, --global otherwise - Add 'gitkit config show' subcommand - Detect already-set configs and show 'already set' message - Export ConfigScope for use in wizard
1 parent aca7ebb commit 1396bc8

2 files changed

Lines changed: 148 additions & 36 deletions

File tree

src/config/mod.rs

Lines changed: 143 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
22
use clap::{Subcommand, ValueEnum};
33
use std::process::Command;
44

5-
use crate::utils::confirm;
5+
use crate::utils::{confirm, find_repo_root};
66

77
#[derive(Subcommand)]
88
pub enum ConfigCommand {
@@ -13,7 +13,13 @@ pub enum ConfigCommand {
1313
yes: bool,
1414
#[arg(long)]
1515
dry_run: bool,
16+
#[arg(long, conflicts_with = "local")]
17+
global: bool,
18+
#[arg(long, conflicts_with = "global")]
19+
local: bool,
1620
},
21+
/// Show current git config values
22+
Show,
1723
}
1824

1925
#[derive(ValueEnum, Clone)]
@@ -27,15 +33,90 @@ pub enum Preset {
2733
}
2834

2935
pub fn run(cmd: ConfigCommand) -> Result<()> {
30-
let ConfigCommand::Apply {
31-
preset,
32-
yes,
33-
dry_run,
34-
} = cmd;
35-
match preset {
36-
Preset::Defaults => apply_defaults(dry_run),
37-
Preset::Advanced => apply_advanced(dry_run),
38-
Preset::Delta => apply_delta(yes, dry_run),
36+
match cmd {
37+
ConfigCommand::Apply {
38+
preset,
39+
yes,
40+
dry_run,
41+
global,
42+
local,
43+
} => {
44+
let scope = determine_scope(global, local);
45+
match preset {
46+
Preset::Defaults => apply_defaults(dry_run, scope),
47+
Preset::Advanced => apply_advanced(dry_run, scope),
48+
Preset::Delta => apply_delta(yes, dry_run, scope),
49+
}
50+
}
51+
ConfigCommand::Show => show_config(),
52+
}
53+
}
54+
55+
#[derive(Clone, Copy)]
56+
pub(crate) enum ConfigScope {
57+
Global,
58+
Local,
59+
}
60+
61+
fn determine_scope(global: bool, local: bool) -> ConfigScope {
62+
if global {
63+
ConfigScope::Global
64+
} else if local || find_repo_root().is_ok() {
65+
ConfigScope::Local
66+
} else {
67+
ConfigScope::Global
68+
}
69+
}
70+
71+
fn scope_flag(scope: ConfigScope) -> &'static str {
72+
match scope {
73+
ConfigScope::Global => "--global",
74+
ConfigScope::Local => "--local",
75+
}
76+
}
77+
78+
fn show_config() -> Result<()> {
79+
println!("Git config (global):");
80+
show_scope_config("--global");
81+
println!();
82+
println!("Git config (local):");
83+
show_scope_config("--local");
84+
Ok(())
85+
}
86+
87+
fn show_scope_config(scope: &str) {
88+
let configs = [
89+
"push.autoSetupRemote",
90+
"help.autocorrect",
91+
"diff.algorithm",
92+
"merge.conflictstyle",
93+
"rerere.enabled",
94+
"core.pager",
95+
];
96+
97+
let mut any = false;
98+
for key in &configs {
99+
if let Some(value) = git_config_get(key, scope) {
100+
println!(" {key} = {value}");
101+
any = true;
102+
}
103+
}
104+
105+
if !any {
106+
println!(" (none)");
107+
}
108+
}
109+
110+
fn git_config_get(key: &str, scope: &str) -> Option<String> {
111+
let output = Command::new("git")
112+
.args(["config", scope, "--get", key])
113+
.output()
114+
.ok()?;
115+
116+
if output.status.success() {
117+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
118+
} else {
119+
None
39120
}
40121
}
41122

@@ -87,10 +168,12 @@ pub(crate) const CONFIG_OPTIONS: &[ConfigOption] = &[
87168
];
88169

89170
/// Apply selected config option keys. Used by the interactive wizard.
90-
pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result<()> {
171+
pub(crate) fn apply_config_keys(
172+
keys: &[&str],
173+
cargo_available: bool,
174+
scope: ConfigScope,
175+
) -> Result<()> {
91176
for key in keys {
92-
// Find the matching option to reuse its value from CONFIG_OPTIONS context,
93-
// then dispatch to the appropriate setter.
94177
match *key {
95178
"core.pager" => {
96179
anyhow::ensure!(
@@ -101,17 +184,16 @@ pub(crate) fn apply_config_keys(keys: &[&str], cargo_available: bool) -> Result<
101184
install_delta()?;
102185
}
103186
for (k, v) in DELTA_CONFIGS {
104-
git_config_set(k, v)?;
187+
git_config_set(k, v, scope)?;
105188
}
106189
}
107190
_ => {
108-
// All non-delta options map directly from CONFIG_OPTIONS value
109191
let value = CONFIG_OPTIONS
110192
.iter()
111193
.find(|o| o.key == *key)
112194
.and_then(|o| o.value)
113195
.ok_or_else(|| anyhow::anyhow!("Unknown config key: {key}"))?;
114-
git_config_set(key, value)?;
196+
git_config_set(key, value, scope)?;
115197
}
116198
}
117199
}
@@ -138,18 +220,18 @@ const DELTA_CONFIGS: GitConfigs = &[
138220
("delta.side-by-side", "true"),
139221
];
140222

141-
fn apply_defaults(dry_run: bool) -> Result<()> {
142-
apply_configs(DEFAULTS, dry_run)
223+
fn apply_defaults(dry_run: bool, scope: ConfigScope) -> Result<()> {
224+
apply_configs(DEFAULTS, dry_run, scope)
143225
}
144226

145-
fn apply_advanced(dry_run: bool) -> Result<()> {
227+
fn apply_advanced(dry_run: bool, scope: ConfigScope) -> Result<()> {
146228
println!(
147229
"Warning: merge.conflictstyle=zdiff3 may cause issues with GitHub Desktop and GUI merge tools."
148230
);
149-
apply_configs(ADVANCED, dry_run)
231+
apply_configs(ADVANCED, dry_run, scope)
150232
}
151233

152-
fn apply_delta(yes: bool, dry_run: bool) -> Result<()> {
234+
fn apply_delta(yes: bool, dry_run: bool, scope: ConfigScope) -> Result<()> {
153235
if !delta_installed() {
154236
if !confirm(
155237
"git-delta is not installed. Install via `cargo install git-delta`?",
@@ -166,29 +248,44 @@ fn apply_delta(yes: bool, dry_run: bool) -> Result<()> {
166248
}
167249
println!(
168250
"Note: delta.side-by-side=true may look wrong in narrow terminals. \
169-
Disable with: git config --global delta.side-by-side false"
251+
Disable with: git config {} delta.side-by-side false",
252+
scope_flag(scope)
170253
);
171-
apply_configs(DELTA_CONFIGS, dry_run)
254+
apply_configs(DELTA_CONFIGS, dry_run, scope)
172255
}
173256

174-
fn apply_configs(configs: GitConfigs, dry_run: bool) -> Result<()> {
257+
fn apply_configs(configs: GitConfigs, dry_run: bool, scope: ConfigScope) -> Result<()> {
258+
let flag = scope_flag(scope);
259+
let mut already_set = 0;
260+
175261
for (key, value) in configs {
176-
if dry_run {
177-
println!("[dry-run] git config --global {key} {value}");
262+
let current = git_config_get(key, flag);
263+
264+
if current.as_deref() == Some(value) {
265+
println!("✓ {key} = {value} (already set)");
266+
already_set += 1;
267+
} else if dry_run {
268+
println!("[dry-run] git config {flag} {key} {value}");
178269
} else {
179-
git_config_set(key, value)?;
180-
println!("Set {key} = {value}");
270+
git_config_set(key, value, scope)?;
271+
println!("Set {key} = {value}");
181272
}
182273
}
274+
275+
if already_set == configs.len() {
276+
println!("\nAll configs already applied.");
277+
}
278+
183279
Ok(())
184280
}
185281

186-
fn git_config_set(key: &str, value: &str) -> Result<()> {
282+
fn git_config_set(key: &str, value: &str, scope: ConfigScope) -> Result<()> {
283+
let flag = scope_flag(scope);
187284
let status = Command::new("git")
188-
.args(["config", "--global", key, value])
285+
.args(["config", flag, key, value])
189286
.status()
190287
.with_context(|| format!("Failed to run git config for '{key}'"))?;
191-
anyhow::ensure!(status.success(), "git config --global {key} {value} failed");
288+
anyhow::ensure!(status.success(), "git config {flag} {key} {value} failed");
192289
Ok(())
193290
}
194291

@@ -224,18 +321,29 @@ mod tests {
224321

225322
#[test]
226323
fn apply_configs_dry_run_prints_without_running_git() {
227-
// dry_run=true must not invoke git; if it did it would fail in CI without a repo
228-
let result = apply_configs(DEFAULTS, true);
324+
let result = apply_configs(DEFAULTS, true, ConfigScope::Global);
229325
assert!(result.is_ok());
230326
}
231327

232328
#[test]
233329
fn apply_configs_dry_run_covers_advanced_preset() {
234-
assert!(apply_configs(ADVANCED, true).is_ok());
330+
assert!(apply_configs(ADVANCED, true, ConfigScope::Global).is_ok());
235331
}
236332

237333
#[test]
238334
fn apply_configs_dry_run_covers_delta_preset() {
239-
assert!(apply_configs(DELTA_CONFIGS, true).is_ok());
335+
assert!(apply_configs(DELTA_CONFIGS, true, ConfigScope::Global).is_ok());
336+
}
337+
338+
#[test]
339+
fn determine_scope_defaults_to_global_outside_repo() {
340+
let scope = determine_scope(false, false);
341+
assert!(matches!(scope, ConfigScope::Global | ConfigScope::Local));
342+
}
343+
344+
#[test]
345+
fn determine_scope_respects_explicit_flags() {
346+
assert!(matches!(determine_scope(true, false), ConfigScope::Global));
347+
assert!(matches!(determine_scope(false, true), ConfigScope::Local));
240348
}
241349
}

src/init.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ pub fn run() -> Result<()> {
185185
println!(" ◇ .gitattributes applied ✓");
186186
}
187187
if !selected_config_keys.is_empty() {
188-
config::apply_config_keys(&selected_config_keys, cargo_available)?;
188+
config::apply_config_keys(
189+
&selected_config_keys,
190+
cargo_available,
191+
config::ConfigScope::Local,
192+
)?;
189193
println!(" ◇ git config applied ✓");
190194
}
191195

0 commit comments

Comments
 (0)