diff --git a/README.md b/README.md index fdeb853b..d01afee6 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,44 @@ passless config print > ~/.config/passless/config.toml You can then edit this file to customize the storage backend, security settings, and other options. Command-line arguments will override settings from the configuration file. +### Password-store Git synchronization + +By default, the `pass` backend pulls changes, creates local Git commits, and pushes those commits +when a remote is configured. To retain automatic pulls and local commits but **disable automatic +pushes**, configure: + +```toml +[pass] +git_sync = true +git_auto_push = false +``` + +The default is `git_auto_push = true` when the setting is omitted, preserving existing behavior. +You can also use `--pass-git-auto-push=false` or `PASSLESS_PASS_GIT_AUTO_PUSH=false`. +With automatic pushes disabled, use `git -C ~/.password-store push` when you want to publish +local commits. Setting `git_sync = false` instead disables automatic pull and commit as well. + +**Agent profiles use separate Git settings.** An isolated agent with a `pass` backend +uses `[agents.profiles..storage.pass]` with its own `git_sync` and +`git_auto_push` settings (both default to `true`); it does not inherit the global +`[pass]` values. For example: + +```toml +[pass] +git_auto_push = false # Human backend only + +[agents.profiles.release-bot.storage.pass] +store_path = "/home/user/.password-store-agents" +path = "fido2" +pin_path = "pin" +git_auto_push = true # This isolated agent still pushes +``` + +A `same-user` agent shares the human backend and follows `[pass]`. See +[agent storage configuration](docs/agents/configuration.md#storage-backends) for details. +Because a Git push publishes all reachable commits, use **separate Git repositories** +for human and isolated-agent stores when you need strict push isolation. + ### Headless automatic interaction By default, Passless uses the desktop notification service when a WebAuthn operation needs local diff --git a/cmd/passless/src/agent/storage_factory.rs b/cmd/passless/src/agent/storage_factory.rs index f86c9e6b..9714e90b 100644 --- a/cmd/passless/src/agent/storage_factory.rs +++ b/cmd/passless/src/agent/storage_factory.rs @@ -293,16 +293,20 @@ pub fn create_storage_bundle_with_options( ref path, ref gpg_backend, ref pin_path, + git_sync, + git_auto_push, } => { let gpg = gpg_backend.parse::().map_err(|e| { Error::Storage(format!("invalid GPG backend '{}': {}", gpg_backend, e)) })?; - let cred_storage = crate::storage::PassStorageAdapter::new_with_options( + let cred_storage = crate::storage::PassStorageAdapter::new_with_options_and_git_sync( store_path.clone(), PathBuf::from(path), gpg, allow_create_without_prompt, + git_sync, + git_auto_push, ) .map_err(|e| { Error::Storage(format!( @@ -881,6 +885,8 @@ mod tests { path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("/absolute/pin"), + git_sync: true, + git_auto_push: true, }; let result = config.validate(); assert!(result.is_err()); @@ -894,6 +900,8 @@ mod tests { path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("../escape"), + git_sync: true, + git_auto_push: true, }; let result = config.validate(); assert!(result.is_err()); @@ -911,6 +919,8 @@ mod tests { path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("pin"), + git_sync: true, + git_auto_push: true, }; let pin_path = config.pin_state_path(); let expected = passless_core::BackendConfig::canonicalize_path(&store.join("pin")); @@ -2088,6 +2098,8 @@ mod tests { path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("pin"), + git_sync: true, + git_auto_push: true, }; let _gpg_home_guard = unsafe { ProcessEnvGuard::set("GNUPGHOME", &gpg_home) }; diff --git a/cmd/passless/src/main.rs b/cmd/passless/src/main.rs index d32bbe31..389ea599 100644 --- a/cmd/passless/src/main.rs +++ b/cmd/passless/src/main.rs @@ -467,6 +467,7 @@ fn run() -> Result<()> { })?; let pass_git_sync = config.pass.git_sync; + let pass_git_auto_push = config.pass.git_auto_push; #[cfg(feature = "agent")] let agent_enabled = config.agents.enabled; @@ -604,6 +605,7 @@ fn run() -> Result<()> { gpg_backend, allow_storage_creation, pass_git_sync, + pass_git_auto_push, )?; let pass_sync = storage.sync_handle(); let boxed: Box = Box::new(storage); @@ -817,6 +819,7 @@ fn run() -> Result<()> { gpg_backend, allow_storage_creation, pass_git_sync, + pass_git_auto_push, )?; let pass_sync = storage.sync_handle(); let pin_storage = PassPinStorage::new_with_sync( diff --git a/cmd/passless/src/pin_storage/pass.rs b/cmd/passless/src/pin_storage/pass.rs index 685f840d..4121f225 100644 --- a/cmd/passless/src/pin_storage/pass.rs +++ b/cmd/passless/src/pin_storage/pass.rs @@ -495,7 +495,7 @@ mod tests { #[test] fn cached_pin_configuration_avoids_storage_load() { let temp = tempfile::tempdir().unwrap(); - let sync = PassGitSync::new(temp.path().to_path_buf(), false); + let sync = PassGitSync::new(temp.path().to_path_buf(), false, false); let storage = PassPinStorage::new_with_sync( temp.path().to_path_buf(), PathBuf::from("fido2"), @@ -521,7 +521,7 @@ mod tests { #[test] fn retry_state_version_and_timestamp_churn_do_not_change_synced_config() { let temp = tempfile::tempdir().unwrap(); - let sync = PassGitSync::new(temp.path().to_path_buf(), false); + let sync = PassGitSync::new(temp.path().to_path_buf(), false, false); let storage = PassPinStorage::new_with_sync( temp.path().to_path_buf(), PathBuf::from("fido2"), diff --git a/cmd/passless/src/storage/pass/sync.rs b/cmd/passless/src/storage/pass/sync.rs index 9fd62ca8..9136ddc3 100644 --- a/cmd/passless/src/storage/pass/sync.rs +++ b/cmd/passless/src/storage/pass/sync.rs @@ -30,6 +30,7 @@ pub struct PassGitSync { struct SyncState { store_path: PathBuf, enabled: bool, + auto_push: bool, depth: usize, prepared: bool, dirty: bool, @@ -37,11 +38,12 @@ struct SyncState { } impl PassGitSync { - pub fn new(store_path: PathBuf, enabled: bool) -> Self { + pub fn new(store_path: PathBuf, enabled: bool, auto_push: bool) -> Self { Self { state: Arc::new(Mutex::new(SyncState { store_path, enabled, + auto_push, depth: 0, prepared: false, dirty: false, @@ -131,7 +133,7 @@ impl PassGitSync { } /// Finish a logical operation. The outermost operation produces at most one - /// Git commit and push, irrespective of how many credential/PIN writes occurred. + /// Git commit (and optionally push), irrespective of how many writes occurred. pub fn end_operation(&self) { let Ok(mut state) = self.state.lock() else { warn!("Failed to acquire pass Git sync lock while ending operation"); @@ -168,7 +170,7 @@ impl PassGitSync { _ => "Update Passless authenticator state.".to_string(), }; - match Self::finalize_store(&state.store_path, &message) { + match Self::finalize_store(&state.store_path, &message, state.auto_push) { Ok(()) => { state.dirty = false; state.messages.clear(); @@ -250,8 +252,23 @@ impl PassGitSync { .map_err(|error| format!("prepare failed: {error:?}")) } - fn finalize_store(store_path: &Path, message: &str) -> Result<(), String> { + fn finalize_store(store_path: &Path, message: &str, auto_push: bool) -> Result<(), String> { debug!("Finalizing password-store Git sync: {message}"); + + if !auto_push { + // prs-lib's finalize() always pushes when a remote is configured. + // Use its own commit implementation to retain local commits without + // reaching any remote or needing the SSH helper. + let store = Store::open(store_path.to_string_lossy().as_ref()) + .map_err(|error| format!("failed to open store: {error:?}"))?; + let sync = store.sync(); + return if sync.is_init() { + sync.commit_all(message, false) + .map_err(|error| format!("commit failed: {error:?}")) + } else { + Ok(()) + }; + } if let Some(ssh_auth_sock) = configured_ssh_auth_sock() { return run_sync_helper( store_path, @@ -362,7 +379,7 @@ mod tests { #[test] fn nested_operations_do_not_finalize_inner_boundary() { let temp = tempfile::tempdir().unwrap(); - let sync = PassGitSync::new(temp.path().to_path_buf(), true); + let sync = PassGitSync::new(temp.path().to_path_buf(), true, true); sync.begin_operation(); sync.begin_operation(); @@ -380,7 +397,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let repo = Repository::init(temp.path()).unwrap(); let local = temp.path().join("fido2/pin_retries.local.gpg"); - let sync = PassGitSync::new(temp.path().to_path_buf(), false); + let sync = PassGitSync::new(temp.path().to_path_buf(), false, true); sync.ensure_local_only_path(&local); @@ -392,6 +409,109 @@ mod tests { ); } + #[test] + fn commit_only_mode_never_pushes_to_remote() { + const CHILD: &str = "PASSLESS_GIT_SYNC_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + // prs-lib executes the external git binary. Other unit tests temporarily + // replace the process-wide PATH, so exercise Git in a separate process. + let binary = std::env::current_exe().unwrap(); + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut git_paths = vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/opt/local/bin"), + ]; + git_paths.extend(std::env::split_paths(&path)); + let output = Command::new(binary) + .arg("--exact") + .arg("storage::pass::sync::tests::commit_only_mode_never_pushes_to_remote") + .arg("--nocapture") + .env(CHILD, "1") + .env("PATH", std::env::join_paths(git_paths).unwrap()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env( + "GIT_CONFIG_GLOBAL", + if cfg!(windows) { "NUL" } else { "/dev/null" }, + ) + .env_remove(PASS_GIT_SSH_AUTH_SOCK_ENV) + .output() + .unwrap(); + assert!( + output.status.success(), + "isolated commit-only Git test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("password-store"); + let remote_path = temp.path().join("remote.git"); + let repository = Repository::init(&store_path).unwrap(); + let remote = Repository::init_bare(&remote_path).unwrap(); + repository + .remote("origin", remote_path.to_str().unwrap()) + .unwrap(); + let mut config = repository.config().unwrap(); + config.set_str("user.name", "Passless Test").unwrap(); + config + .set_str("user.email", "passless@example.invalid") + .unwrap(); + + // Seed an actual remote branch. Checking an initially empty bare remote + // would not detect an accidental push to an unexpected branch. + fs::write(store_path.join(".gpg-id"), b"test@example.invalid\n").unwrap(); + let mut index = repository.index().unwrap(); + index.add_path(Path::new(".gpg-id")).unwrap(); + index.write().unwrap(); + let tree = repository.find_tree(index.write_tree().unwrap()).unwrap(); + let signature = git2::Signature::now("Passless Test", "passless@example.invalid").unwrap(); + let seed = repository + .commit( + Some("HEAD"), + &signature, + &signature, + "Initial store", + &tree, + &[], + ) + .unwrap(); + let branch = repository.head().unwrap().shorthand().unwrap().to_string(); + repository + .find_remote("origin") + .unwrap() + .push(&[format!("refs/heads/{branch}:refs/heads/{branch}")], None) + .unwrap(); + let remote_ref = format!("refs/heads/{branch}"); + assert_eq!( + remote.find_reference(&remote_ref).unwrap().target(), + Some(seed) + ); + + fs::write(store_path.join("credential.gpg"), b"test encrypted bytes").unwrap(); + let sync = PassGitSync::new(store_path.clone(), true, false); + sync.begin_operation(); + sync.mark_dirty("Update credential"); + sync.end_operation(); + + assert!( + !sync.state.lock().unwrap().dirty, + "Git commit-only finalization failed" + ); + let commit = repository.head().unwrap().peel_to_commit().unwrap(); + assert_eq!(commit.message(), Ok("Update credential\n")); + assert_ne!(commit.id(), seed, "the change must be committed locally"); + assert_eq!( + remote.find_reference(&remote_ref).unwrap().target(), + Some(seed), + "commit-only mode must leave the remote branch unchanged" + ); + } + #[test] fn helper_is_resolved_next_to_passless_binary() { let current = Path::new("/usr/bin/passless"); diff --git a/cmd/passless/src/storage/pass_refresh.rs b/cmd/passless/src/storage/pass_refresh.rs index c2a93888..1e4a8c47 100644 --- a/cmd/passless/src/storage/pass_refresh.rs +++ b/cmd/passless/src/storage/pass_refresh.rs @@ -52,6 +52,7 @@ impl PassStorageAdapter { gpg_backend, allow_create_without_prompt, true, + true, ) } @@ -61,6 +62,7 @@ impl PassStorageAdapter { gpg_backend: GpgBackend, allow_create_without_prompt: bool, git_sync: bool, + git_auto_push: bool, ) -> Result { let inner = InnerPassStorageAdapter::new_with_options( store_path.clone(), @@ -69,7 +71,7 @@ impl PassStorageAdapter { allow_create_without_prompt, )?; let indexed_generation = repository_generation(&store_path, &path); - let sync = PassGitSync::new(store_path.clone(), git_sync); + let sync = PassGitSync::new(store_path.clone(), git_sync, git_auto_push); let mut adapter = Self { inner, diff --git a/cmd/passless/tests/agent_config_integration.rs b/cmd/passless/tests/agent_config_integration.rs index 4ba90d67..d5688d83 100644 --- a/cmd/passless/tests/agent_config_integration.rs +++ b/cmd/passless/tests/agent_config_integration.rs @@ -300,6 +300,8 @@ fn pass_storage_pin_path_absolute_rejected() { path: "fido2".into(), gpg_backend: "gnupg-bin".into(), pin_path: PathBuf::from("/absolute/pin"), + git_sync: true, + git_auto_push: true, }; let err = storage.validate().unwrap_err(); assert!(err.to_string().contains("relative subpath")); @@ -312,6 +314,8 @@ fn pass_storage_pin_path_traversal_rejected() { path: "fido2".into(), gpg_backend: "gnupg-bin".into(), pin_path: PathBuf::from("../escape"), + git_sync: true, + git_auto_push: true, }; let err = storage.validate().unwrap_err(); assert!(err.to_string().contains("path traversal")); diff --git a/cmd/passless/tests/cli_integration.rs b/cmd/passless/tests/cli_integration.rs index 032ea54b..cfb451fe 100644 --- a/cmd/passless/tests/cli_integration.rs +++ b/cmd/passless/tests/cli_integration.rs @@ -52,6 +52,7 @@ fn config_print_exits_zero() { assert!(stdout.contains("backend_type")); assert!(stdout.contains("[security]")); assert!(stdout.contains("[pin]")); + assert!(stdout.contains("git_auto_push = true")); } #[test] diff --git a/cmd/passless/tests/config_integration.rs b/cmd/passless/tests/config_integration.rs index 682061ec..9bbe96e1 100644 --- a/cmd/passless/tests/config_integration.rs +++ b/cmd/passless/tests/config_integration.rs @@ -28,10 +28,94 @@ fn valid_minimal_toml_loads_with_defaults() { let path = write_config(dir.path(), r#"backend_type = "local""#); let config = load_from_file(&path).unwrap(); assert_eq!(config.backend_type, "local"); + assert!(config.pass.git_sync); + assert!(config.pass.git_auto_push); assert!(config.security.always_uv); assert_eq!(config.pin.min_length, 4); } +#[test] +fn pass_git_auto_push_false_preserves_git_sync() { + let dir = tempdir().unwrap(); + let path = write_config(dir.path(), "[pass]\ngit_auto_push = false\n"); + let config = load_from_file(&path).unwrap(); + assert!(config.pass.git_sync); + assert!(!config.pass.git_auto_push); +} + +#[cfg(feature = "agent")] +#[test] +fn isolated_agent_has_independent_pass_git_configuration() { + use passless_core::agent::AgentStorageConfig; + + let dir = tempdir().unwrap(); + let path = write_config( + dir.path(), + r#" +[pass] +git_sync = false +git_auto_push = false + +[agents] +enabled = false + +[agents.profiles.bot] +mode = "isolated" +principal_user = "agent" + +[agents.profiles.bot.storage.pass] +store_path = "/tmp/agent-password-store" +path = "fido2" +pin_path = "pin" +git_sync = true +git_auto_push = true +"#, + ); + let config = load_from_file(&path).unwrap(); + assert!(!config.pass.git_sync); + assert!(!config.pass.git_auto_push); + let profile = config.agents.profiles.get("bot").unwrap(); + match profile.storage.as_ref().unwrap() { + AgentStorageConfig::Pass { + git_sync, + git_auto_push, + .. + } => { + assert!(*git_sync); + assert!(*git_auto_push); + } + other => panic!("expected isolated pass backend, got {other:?}"), + } +} + +#[test] +fn cli_can_override_git_auto_push_in_config_file() { + let dir = tempdir().unwrap(); + let path = write_config(dir.path(), "[pass]\ngit_auto_push = false\n"); + let mut args = Args::try_parse_from([ + "passless", + "--config-path", + &path.display().to_string(), + "--pass-git-auto-push=true", + ]) + .unwrap(); + let config = AppConfig::load(&mut args).unwrap(); + assert!(config.pass.git_auto_push); +} + +#[test] +fn cli_can_disable_git_auto_push() { + let mut args = Args::try_parse_from([ + "passless", + "--config-path", + "/nonexistent/path/config.toml", + "--pass-git-auto-push=false", + ]) + .unwrap(); + let config = AppConfig::load(&mut args).unwrap(); + assert!(!config.pass.git_auto_push); +} + #[test] fn unknown_top_level_field_is_silently_ignored() { let dir = tempdir().unwrap(); @@ -212,6 +296,9 @@ fn config_print_produces_valid_toml() { let toml_str = config.to_toml_with_comments(); assert!(toml_str.contains("backend_type")); assert!(toml_str.contains("[security]")); + let parsed: toml::Value = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed["pass"]["git_auto_push"].as_bool(), Some(true)); + assert!(toml_str.contains("git_auto_push = true")); assert!(toml_str.contains("[pin]")); } diff --git a/docs/agents/configuration.md b/docs/agents/configuration.md index c184f282..a67f4bcd 100644 --- a/docs/agents/configuration.md +++ b/docs/agents/configuration.md @@ -204,6 +204,8 @@ store_path = "/path/to/password-store" path = "agent/" gpg_backend = "gnupg-bin" pin_path = "agent/-pin" +git_sync = true +git_auto_push = false # TPM, when compiled with TPM support [agents.profiles..storage.tpm] @@ -213,6 +215,21 @@ pin_path = "/var/lib/passless-agent//pin" portable = false ``` +For an isolated profile using `storage.pass`, `git_sync` and `git_auto_push` are +**profile-specific** and both default to `true` when omitted. Setting +`git_auto_push = false` keeps automatic pulls and local commits but prevents automatic +pushes for that profile. Setting `git_sync = false` disables automatic Git pulls, +commits, and pushes for that profile. These options are independent of the top-level +`[pass]` settings: disabling pushes for the human backend does **not** disable them +for isolated agents, and vice versa. A `same-user` profile uses the human backend +and therefore follows the global `[pass]` settings instead. + +**Git repository boundaries matter:** these flags control whether each backend initiates a +push, not which commits are included. If the human backend and an isolated agent share +the same Git repository, a push from either backend can publish the other's unpushed +commits. Use separate Git repositories (distinct `store_path` roots) when pushes must +be isolated between human credentials and agent credentials. + ## Device identity Isolated profiles require a complete, unique UHID device identity: diff --git a/passless-core/src/agent/config.rs b/passless-core/src/agent/config.rs index ea673408..dadc801b 100644 --- a/passless-core/src/agent/config.rs +++ b/passless-core/src/agent/config.rs @@ -370,6 +370,10 @@ fn default_gpg_backend() -> String { "gnupg-bin".to_string() } +fn default_git_enabled() -> bool { + true +} + #[cfg(feature = "tpm")] fn default_tcti() -> String { "device:/dev/tpmrm0".to_string() @@ -388,6 +392,12 @@ pub enum AgentStorageConfig { #[serde(default = "default_gpg_backend")] gpg_backend: String, pin_path: PathBuf, + /// Synchronize this isolated agent's pass store (independent of [pass]). + #[serde(default = "default_git_enabled")] + git_sync: bool, + /// Push this isolated agent's Git commits (independent of [pass]). + #[serde(default = "default_git_enabled")] + git_auto_push: bool, }, #[cfg(feature = "tpm")] Tpm { @@ -2732,6 +2742,8 @@ store_path = "/home/user/.password-store" path = "fido2/ci" gpg_backend = "gpgme" pin_path = "pin" +git_sync = true +git_auto_push = false "#; let config: AgentConfig = toml::from_str(toml_str).unwrap(); let profile = config.profiles.get("ci").unwrap(); @@ -2741,14 +2753,21 @@ pin_path = "pin" path, gpg_backend, pin_path, + git_sync, + git_auto_push, }) => { assert_eq!(store_path, &PathBuf::from("/home/user/.password-store")); assert_eq!(path, "fido2/ci"); assert_eq!(gpg_backend, "gpgme"); assert_eq!(pin_path, &PathBuf::from("pin")); + assert!(*git_sync); + assert!(!*git_auto_push); } other => panic!("expected Pass storage, got: {:?}", other), } + let serialized = toml::to_string(&config).unwrap(); + let decoded: AgentConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(serialized, toml::to_string(&decoded).unwrap()); } #[test] @@ -2777,8 +2796,15 @@ pin_path = "pin" let config: AgentConfig = toml::from_str(toml_str).unwrap(); let profile = config.profiles.get("ci").unwrap(); match &profile.storage { - Some(AgentStorageConfig::Pass { gpg_backend, .. }) => { + Some(AgentStorageConfig::Pass { + gpg_backend, + git_sync, + git_auto_push, + .. + }) => { assert_eq!(gpg_backend, "gnupg-bin"); + assert!(*git_sync); + assert!(*git_auto_push); } other => panic!("expected Pass storage, got: {:?}", other), } @@ -2884,6 +2910,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("pin"), + git_sync: true, + git_auto_push: true, }; let backend = storage.to_backend_config(); match backend { @@ -3011,6 +3039,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("pin"), + git_sync: true, + git_auto_push: true, }; assert_eq!(format!("{}", pass), "pass"); } @@ -3178,6 +3208,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" path: "fido2".to_string(), gpg_backend: "gnupg-bin".to_string(), pin_path: PathBuf::from("pin"), + git_sync: true, + git_auto_push: true, }; let cred_path = storage.credential_state_path(); let expected = crate::config::BackendConfig::canonicalize_path(&store.join("fido2")); diff --git a/passless-core/src/config.rs b/passless-core/src/config.rs index 74a1f534..c4193851 100644 --- a/passless-core/src/config.rs +++ b/passless-core/src/config.rs @@ -104,6 +104,20 @@ pub struct PassBackendConfig { #[serde(default)] #[default(true)] pub git_sync: bool, + + /// Automatically push password-store Git commits to the remote (default: true). + /// When disabled, Git sync still pulls and commits changes locally. + #[arg( + long = "pass-git-auto-push", + env = "PASSLESS_PASS_GIT_AUTO_PUSH", + action = ArgAction::Set, + require_equals = true, + num_args = 0..=1, + default_missing_value = "true" + )] + #[serde(default)] + #[default(true)] + pub git_auto_push: bool, } /// Compute default TPM storage path