git_remote_object_store/lfs/install.rs
1//! Implementations of the `install`, `enable-debug`, and
2//! `disable-debug` subcommands.
3//!
4//! Each rewrites the local repository's `.git/config` in-process via
5//! [`crate::git::config_set`] / [`crate::git::config_unset_if_present`]
6//! (which use `gix-config` for parsing and `gix-lock` for the atomic-rename
7//! write) to wire the LFS agent into the repository. All three subcommands
8//! are idempotent: re-running them on an already-installed (or already-
9//! uninstalled) repo is a no-op rather than producing duplicate entries or
10//! a "key not set" error (see issues #198 and #210).
11
12use std::path::Path;
13
14use thiserror::Error;
15
16use crate::git::{self, GitError};
17
18/// Custom-transfer agent name registered with `git lfs`. The keys
19/// `lfs.customtransfer.<name>.*` are namespaced under this; matches
20/// the binary name (`git-lfs-object-store`).
21pub const AGENT_NAME: &str = "git-lfs-object-store";
22
23const KEY_STANDALONE: &str = "lfs.standalonetransferagent";
24
25/// `lfs.customtransfer.<AGENT_NAME>.path` — composed from [`AGENT_NAME`]
26/// at compile time via `concat!` so renaming the agent cannot silently
27/// break LFS routing (issue #190) and no per-call allocation occurs.
28const KEY_PATH: &str = concat!("lfs.customtransfer.", "git-lfs-object-store", ".path");
29
30/// `lfs.customtransfer.<AGENT_NAME>.args` — see [`KEY_PATH`].
31const KEY_ARGS: &str = concat!("lfs.customtransfer.", "git-lfs-object-store", ".args");
32
33#[cfg(test)]
34const _: () = {
35 // Compile-time guard: if `AGENT_NAME` ever drifts from the literal
36 // substring baked into the keys above, this assertion stops the
37 // build. `concat!` only accepts string literals so we cannot inline
38 // `AGENT_NAME` directly — the runtime test below catches drift in
39 // CI, and this const_assert catches it during type-check on every
40 // build that touches this module.
41 let agent = AGENT_NAME.as_bytes();
42 let needle = b"git-lfs-object-store";
43 assert!(agent.len() == needle.len());
44 let mut i = 0;
45 while i < needle.len() {
46 assert!(agent[i] == needle[i]);
47 i += 1;
48 }
49};
50
51/// Errors surfaced by the install / debug-toggle subcommands.
52#[derive(Debug, Error)]
53pub enum InstallError {
54 /// Underlying `git config` invocation failed.
55 #[error(transparent)]
56 Git(#[from] GitError),
57}
58
59/// Register the agent with `git lfs` in the repository at `cwd`.
60///
61/// Two writes, batched into a single read / parse / lock / write cycle:
62/// - `lfs.customtransfer.<AGENT_NAME>.path` → the binary name.
63/// - `lfs.standalonetransferagent` → [`AGENT_NAME`], telling LFS to
64/// bypass the HTTP transfer queue and call us directly.
65///
66/// Idempotent: if both keys already hold the expected single value, no
67/// write occurs. Legacy multi-valued state from older binaries (#198) is
68/// collapsed to the canonical single entry on the next call.
69///
70/// # Errors
71///
72/// Returns [`InstallError::Git`] if writing the config entries fails.
73pub fn install(cwd: &Path) -> Result<(), InstallError> {
74 git::config_set_many(cwd, &[(KEY_PATH, AGENT_NAME), (KEY_STANDALONE, AGENT_NAME)])?;
75 Ok(())
76}
77
78/// Set `lfs.customtransfer.<agent>.args = debug` so the next time git
79/// invokes the agent it forwards the `debug` argv slot, switching the
80/// agent's logging from stderr to a file in `<git-dir>/lfs/tmp/`.
81///
82/// Idempotent: re-running on a repo that already has the key set to
83/// `debug` is a no-op (#198).
84///
85/// # Errors
86///
87/// Returns [`InstallError::Git`] if writing the config entry fails.
88pub fn enable_debug(cwd: &Path) -> Result<(), InstallError> {
89 git::config_set(cwd, KEY_ARGS, "debug")?;
90 Ok(())
91}
92
93/// Inverse of [`enable_debug`]: clear `lfs.customtransfer.<agent>.args`.
94///
95/// Idempotent: if the args key is already absent, returns `Ok(())` without
96/// touching the config file (#210).
97///
98/// # Errors
99///
100/// Returns [`InstallError::Git`] wrapping any [`crate::git::GitError`] from
101/// [`crate::git::config_unset_if_present`] other than `ConfigKeyNotSet`
102/// (which this helper treats as success).
103pub fn disable_debug(cwd: &Path) -> Result<(), InstallError> {
104 git::config_unset_if_present(cwd, KEY_ARGS)?;
105 Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110 use super::{AGENT_NAME, KEY_ARGS, KEY_PATH};
111
112 // Drift between `AGENT_NAME` and the config keys is what issue #190
113 // is about — `git lfs` looks up the agent by exact string match on
114 // the subsection, so a mismatch silently sends LFS traffic to the
115 // HTTPS transfer queue instead of the helper. The `concat!`-built
116 // constants plus the `const _: () = { ... }` compile-time check
117 // make drift impossible at build time; these tests pin the
118 // resulting shape so a future refactor cannot quietly change it.
119 #[test]
120 fn key_path_embeds_agent_name() {
121 assert!(
122 KEY_PATH.contains(AGENT_NAME),
123 "KEY_PATH = {KEY_PATH:?} must contain AGENT_NAME = {AGENT_NAME:?}",
124 );
125 }
126
127 #[test]
128 fn key_args_embeds_agent_name() {
129 assert!(
130 KEY_ARGS.contains(AGENT_NAME),
131 "KEY_ARGS = {KEY_ARGS:?} must contain AGENT_NAME = {AGENT_NAME:?}",
132 );
133 }
134
135 #[test]
136 fn key_shapes_match_lfs_customtransfer_namespace() {
137 assert_eq!(KEY_PATH, format!("lfs.customtransfer.{AGENT_NAME}.path"));
138 assert_eq!(KEY_ARGS, format!("lfs.customtransfer.{AGENT_NAME}.args"));
139 }
140
141 // --- idempotency contract (#198, #210) ----------------------------
142 //
143 // These tests pin the end-to-end behaviour callers rely on: re-running
144 // `install` / `enable_debug` / `disable_debug` on a repo where the
145 // target state is already in place must be a no-op rather than
146 // accumulating duplicate config entries or returning an error.
147
148 use std::path::Path;
149 use tempfile::TempDir;
150
151 use super::{KEY_STANDALONE, disable_debug, enable_debug, install};
152 use crate::git;
153
154 fn empty_repo() -> (TempDir, std::path::PathBuf) {
155 let dir = TempDir::new().expect("tempdir");
156 gix::init(dir.path()).expect("gix::init");
157 let path = dir.path().to_path_buf();
158 (dir, path)
159 }
160
161 fn config_values(cwd: &Path, key: &str) -> Vec<String> {
162 let repo = gix::discover(cwd).expect("discover");
163 let path = repo.common_dir().join("config");
164 let bytes = std::fs::read(&path).expect("read config");
165 let file = gix::config::File::from_bytes_no_includes(
166 &bytes,
167 gix::config::file::Metadata::api(),
168 gix::config::file::init::Options::default(),
169 )
170 .expect("parse");
171 file.raw_values(key)
172 .map(|values| {
173 values
174 .into_iter()
175 .map(|v| v.into_owned().to_string())
176 .collect()
177 })
178 .unwrap_or_default()
179 }
180
181 #[test]
182 fn install_is_idempotent_on_repeated_calls() {
183 let (_dir, cwd) = empty_repo();
184 install(&cwd).expect("first install");
185 install(&cwd).expect("second install");
186 install(&cwd).expect("third install");
187 // Both keys the install writes must end up with exactly one value.
188 assert_eq!(config_values(&cwd, KEY_PATH), vec![AGENT_NAME.to_owned()],);
189 assert_eq!(
190 config_values(&cwd, KEY_STANDALONE),
191 vec![AGENT_NAME.to_owned()],
192 );
193 }
194
195 #[test]
196 fn install_collapses_legacy_duplicate_entries() {
197 // Reproduces the pre-fix on-disk state: a user who ran the older
198 // binary twice had two entries per key. After upgrading, a single
199 // `install` call must clean that up rather than adding a third.
200 let (_dir, cwd) = empty_repo();
201 git::config_add(&cwd, KEY_PATH, AGENT_NAME).expect("seed 1");
202 git::config_add(&cwd, KEY_PATH, AGENT_NAME).expect("seed 2");
203 git::config_add(&cwd, KEY_STANDALONE, AGENT_NAME).expect("seed 1");
204 git::config_add(&cwd, KEY_STANDALONE, AGENT_NAME).expect("seed 2");
205 // Pre-state guard: confirm the seed step actually produced legacy
206 // multi-valued entries. Without this, a regression that turned
207 // `config_add` into idempotent-set would make the seeds a no-op
208 // and the post-install assertion would still pass — testing only
209 // that `install` produces one entry, not that it collapses two.
210 assert_eq!(
211 config_values(&cwd, KEY_PATH),
212 vec![AGENT_NAME.to_owned(), AGENT_NAME.to_owned()],
213 "seed step must produce two values for the collapse test to be meaningful",
214 );
215 assert_eq!(
216 config_values(&cwd, KEY_STANDALONE),
217 vec![AGENT_NAME.to_owned(), AGENT_NAME.to_owned()],
218 );
219 install(&cwd).expect("install");
220 assert_eq!(config_values(&cwd, KEY_PATH), vec![AGENT_NAME.to_owned()],);
221 assert_eq!(
222 config_values(&cwd, KEY_STANDALONE),
223 vec![AGENT_NAME.to_owned()],
224 );
225 }
226
227 #[test]
228 fn enable_debug_is_idempotent_on_repeated_calls() {
229 let (_dir, cwd) = empty_repo();
230 enable_debug(&cwd).expect("first");
231 enable_debug(&cwd).expect("second");
232 assert_eq!(config_values(&cwd, KEY_ARGS), vec!["debug".to_owned()]);
233 }
234
235 #[test]
236 fn disable_debug_is_idempotent_when_already_absent() {
237 // Issue #210: running disable-debug on a repo that never enabled
238 // debug must succeed cleanly.
239 let (_dir, cwd) = empty_repo();
240 disable_debug(&cwd).expect("disable on empty repo");
241 assert!(config_values(&cwd, KEY_ARGS).is_empty());
242 }
243
244 #[test]
245 fn disable_debug_then_disable_debug_succeeds() {
246 // Symmetry with enable_debug: enable, disable, disable. The second
247 // disable must not error even though the key is already gone.
248 let (_dir, cwd) = empty_repo();
249 enable_debug(&cwd).expect("enable");
250 disable_debug(&cwd).expect("first disable");
251 disable_debug(&cwd).expect("second disable");
252 assert!(config_values(&cwd, KEY_ARGS).is_empty());
253 }
254}