Skip to main content

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}