Skip to main content

git_sshripped_repository/
lib.rs

1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
2#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
3#![allow(clippy::multiple_crate_versions)]
4
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10use git_sshripped_repository_models::{
11    GithubSourceRegistry, RepositoryLocalConfig, RepositoryManifest,
12};
13
14#[must_use]
15pub fn metadata_dir(repo_root: &Path) -> PathBuf {
16    repo_root.join(".git-sshripped")
17}
18
19#[must_use]
20pub fn manifest_file(repo_root: &Path) -> PathBuf {
21    metadata_dir(repo_root).join("manifest.toml")
22}
23
24#[must_use]
25pub fn github_sources_file(repo_root: &Path) -> PathBuf {
26    metadata_dir(repo_root).join("github-sources.toml")
27}
28
29#[must_use]
30pub fn local_config_file(repo_root: &Path) -> PathBuf {
31    metadata_dir(repo_root).join("config.toml")
32}
33
34/// Write the repository manifest to `.git-sshripped/manifest.toml`.
35///
36/// # Errors
37///
38/// Returns an error if the metadata directory cannot be created, the manifest
39/// cannot be serialized, or the file cannot be written.
40pub fn write_manifest(repo_root: &Path, manifest: &RepositoryManifest) -> Result<()> {
41    let dir = metadata_dir(repo_root);
42    fs::create_dir_all(&dir)
43        .with_context(|| format!("failed to create metadata directory {}", dir.display()))?;
44    let text = toml::to_string_pretty(manifest).context("failed to serialize manifest")?;
45    let file = manifest_file(repo_root);
46    fs::write(&file, text).with_context(|| format!("failed to write {}", file.display()))?;
47    Ok(())
48}
49
50/// Read the repository manifest from `.git-sshripped/manifest.toml`.
51///
52/// # Errors
53///
54/// Returns an error if the file cannot be read or parsed.
55pub fn read_manifest(repo_root: &Path) -> Result<RepositoryManifest> {
56    let file = manifest_file(repo_root);
57    let text = fs::read_to_string(&file)
58        .with_context(|| format!("failed to read manifest {}", file.display()))?;
59    toml::from_str(&text).context("failed to parse repository manifest")
60}
61
62/// Read the GitHub source registry, returning a default if the file does not exist.
63///
64/// # Errors
65///
66/// Returns an error if the file exists but cannot be read or parsed.
67pub fn read_github_sources(repo_root: &Path) -> Result<GithubSourceRegistry> {
68    let file = github_sources_file(repo_root);
69    if !file.exists() {
70        return Ok(GithubSourceRegistry::default());
71    }
72    let text = fs::read_to_string(&file)
73        .with_context(|| format!("failed to read github source registry {}", file.display()))?;
74    toml::from_str(&text).context("failed to parse github source registry")
75}
76
77/// Write the GitHub source registry to `.git-sshripped/github-sources.toml`.
78///
79/// # Errors
80///
81/// Returns an error if the metadata directory cannot be created, the registry
82/// cannot be serialized, or the file cannot be written.
83pub fn write_github_sources(repo_root: &Path, registry: &GithubSourceRegistry) -> Result<()> {
84    let dir = metadata_dir(repo_root);
85    fs::create_dir_all(&dir)
86        .with_context(|| format!("failed to create metadata directory {}", dir.display()))?;
87    let text = toml::to_string_pretty(registry).context("failed to serialize github sources")?;
88    let file = github_sources_file(repo_root);
89    fs::write(&file, text)
90        .with_context(|| format!("failed to write github source registry {}", file.display()))?;
91    Ok(())
92}
93
94/// Read the local repository config, returning a default if the file does not exist.
95///
96/// # Errors
97///
98/// Returns an error if the file exists but cannot be read or parsed.
99pub fn read_local_config(repo_root: &Path) -> Result<RepositoryLocalConfig> {
100    let file = local_config_file(repo_root);
101    if !file.exists() {
102        return Ok(RepositoryLocalConfig::default());
103    }
104    let text = fs::read_to_string(&file)
105        .with_context(|| format!("failed to read repository config {}", file.display()))?;
106    toml::from_str(&text).context("failed to parse repository local config")
107}
108
109/// Write the local repository config to `.git-sshripped/config.toml`.
110///
111/// # Errors
112///
113/// Returns an error if the metadata directory cannot be created, the config
114/// cannot be serialized, or the file cannot be written.
115pub fn write_local_config(repo_root: &Path, config: &RepositoryLocalConfig) -> Result<()> {
116    let dir = metadata_dir(repo_root);
117    fs::create_dir_all(&dir)
118        .with_context(|| format!("failed to create metadata directory {}", dir.display()))?;
119    let text = toml::to_string_pretty(config).context("failed to serialize local config")?;
120    let file = local_config_file(repo_root);
121    fs::write(&file, text)
122        .with_context(|| format!("failed to write local config {}", file.display()))?;
123    Ok(())
124}
125
126/// Append filter/diff attribute lines to `.gitattributes` for the given patterns.
127///
128/// # Errors
129///
130/// Returns an error if the `.gitattributes` file cannot be read or written.
131pub fn install_gitattributes(repo_root: &Path, patterns: &[String]) -> Result<()> {
132    let path = repo_root.join(".gitattributes");
133    let mut existing = if path.exists() {
134        fs::read_to_string(&path)
135            .with_context(|| format!("failed to read gitattributes {}", path.display()))?
136    } else {
137        String::new()
138    };
139
140    for pattern in patterns {
141        let line = pattern.strip_prefix('!').map_or_else(
142            || format!("{pattern} filter=git-sshripped diff=git-sshripped"),
143            |negated| format!("{negated} !filter !diff"),
144        );
145        if !existing.lines().any(|item| item.trim() == line) {
146            if !existing.ends_with('\n') && !existing.is_empty() {
147                existing.push('\n');
148            }
149            existing.push_str(&line);
150            existing.push('\n');
151        }
152    }
153
154    fs::write(&path, existing)
155        .with_context(|| format!("failed to write gitattributes {}", path.display()))?;
156    Ok(())
157}
158
159/// Shell-quote a string so it survives interpretation by the shell.
160///
161/// If the string contains no characters that need quoting it is returned as-is.
162/// Otherwise it is wrapped in single quotes with any embedded single quotes
163/// escaped using the `'\''` idiom.
164fn shell_quote(s: &str) -> String {
165    if !s.contains(|c: char| {
166        c.is_whitespace()
167            || matches!(
168                c,
169                '\'' | '"' | '\\' | '(' | ')' | '&' | ';' | '|' | '<' | '>' | '`' | '$' | '!' | '#'
170            )
171    }) {
172        return s.to_string();
173    }
174    format!("'{}'", s.replace('\'', "'\\''"))
175}
176
177/// Install Git filter and diff configuration.
178///
179/// When `linked_worktree` is `true` the values are written to the
180/// worktree-specific config layer (`git config --worktree`) so that a linked
181/// worktree's binary path does not overwrite the shared repository config.
182/// The `extensions.worktreeConfig` extension is enabled automatically when
183/// needed.
184///
185/// For the main worktree (or when `linked_worktree` is `false`) the values
186/// are written to the shared local config (`git config --local`) which acts
187/// as the default fallback for all worktrees.
188///
189/// # Errors
190///
191/// Returns an error if any `git config` command fails.
192pub fn install_git_filters(repo_root: &Path, bin: &str, linked_worktree: bool) -> Result<()> {
193    // When writing to a linked worktree, ensure the worktreeConfig extension
194    // is enabled (idempotent) so that `--worktree` scope is honoured by git.
195    if linked_worktree {
196        let ext_status = Command::new("git")
197            .args(["config", "--local", "extensions.worktreeConfig", "true"])
198            .current_dir(repo_root)
199            .status()
200            .context("failed to enable extensions.worktreeConfig")?;
201
202        if !ext_status.success() {
203            anyhow::bail!("git config failed for key 'extensions.worktreeConfig'");
204        }
205    }
206
207    let scope = if linked_worktree {
208        "--worktree"
209    } else {
210        "--local"
211    };
212
213    let quoted = shell_quote(bin);
214    let pairs = [
215        (
216            "filter.git-sshripped.process".to_string(),
217            format!("{quoted} filter-process"),
218        ),
219        (
220            "filter.git-sshripped.clean".to_string(),
221            format!("{quoted} clean --path %f"),
222        ),
223        (
224            "filter.git-sshripped.smudge".to_string(),
225            format!("{quoted} smudge --path %f"),
226        ),
227        (
228            "filter.git-sshripped.required".to_string(),
229            "true".to_string(),
230        ),
231        (
232            "diff.git-sshripped.textconv".to_string(),
233            format!("{quoted} diff --path %f"),
234        ),
235    ];
236
237    for (key, value) in &pairs {
238        let status = Command::new("git")
239            .args(["config", scope, key.as_str(), value.as_str()])
240            .current_dir(repo_root)
241            .status()
242            .with_context(|| format!("failed to set git config {key}"))?;
243
244        if !status.success() {
245            anyhow::bail!("git config failed for key '{key}'");
246        }
247    }
248    Ok(())
249}
250
251// ---------------------------------------------------------------------------
252// Agent-wrapped key file helpers
253//
254// Agent-wrap files are stored inside the git common directory
255// (`git rev-parse --git-common-dir`) so they are:
256//   - local to the machine (never committed)
257//   - shared across linked worktrees
258// ---------------------------------------------------------------------------
259
260/// Directory for agent-wrapped key files inside the git common directory.
261#[must_use]
262pub fn agent_wrap_dir(common_dir: &Path) -> PathBuf {
263    common_dir.join("git-sshripped-agent-wrap")
264}
265
266/// Path to an agent-wrapped key file for a given fingerprint.
267#[must_use]
268pub fn agent_wrap_file(common_dir: &Path, fingerprint: &str) -> PathBuf {
269    agent_wrap_dir(common_dir).join(format!("{fingerprint}.toml"))
270}
271
272/// Read an agent-wrapped key file, returning `None` if the file does not exist.
273///
274/// # Errors
275///
276/// Returns an error if the file exists but cannot be read or parsed.
277pub fn read_agent_wrap(
278    common_dir: &Path,
279    fingerprint: &str,
280) -> Result<Option<git_sshripped_ssh_agent_models::AgentWrappedKey>> {
281    let file = agent_wrap_file(common_dir, fingerprint);
282    if !file.exists() {
283        return Ok(None);
284    }
285    let text = fs::read_to_string(&file)
286        .with_context(|| format!("failed to read agent-wrap file {}", file.display()))?;
287    let key: git_sshripped_ssh_agent_models::AgentWrappedKey =
288        toml::from_str(&text).context("failed to parse agent-wrap file")?;
289    Ok(Some(key))
290}
291
292/// Write an agent-wrapped key file.
293///
294/// # Errors
295///
296/// Returns an error if the directory cannot be created, the file cannot be
297/// serialized, or the file cannot be written.
298pub fn write_agent_wrap(
299    common_dir: &Path,
300    wrapped: &git_sshripped_ssh_agent_models::AgentWrappedKey,
301) -> Result<()> {
302    let dir = agent_wrap_dir(common_dir);
303    fs::create_dir_all(&dir)
304        .with_context(|| format!("failed to create agent-wrap directory {}", dir.display()))?;
305    let file = agent_wrap_file(common_dir, &wrapped.fingerprint);
306    let text = toml::to_string_pretty(wrapped).context("failed to serialize agent-wrap key")?;
307    fs::write(&file, text)
308        .with_context(|| format!("failed to write agent-wrap file {}", file.display()))?;
309    Ok(())
310}
311
312/// List all agent-wrap `.toml` files in the agent-wrap directory.
313///
314/// # Errors
315///
316/// Returns an error if the directory cannot be read.
317pub fn list_agent_wrap_files(common_dir: &Path) -> Result<Vec<PathBuf>> {
318    let dir = agent_wrap_dir(common_dir);
319    if !dir.exists() {
320        return Ok(Vec::new());
321    }
322    let mut files = Vec::new();
323    for entry in fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? {
324        let entry = entry?;
325        let path = entry.path();
326        if path
327            .extension()
328            .and_then(|ext| ext.to_str())
329            .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
330        {
331            files.push(path);
332        }
333    }
334    Ok(files)
335}
336
337/// Parse an agent-wrapped key from a TOML string.
338///
339/// # Errors
340///
341/// Returns an error if the string is not valid TOML or does not match the
342/// expected schema.
343pub fn parse_agent_wrap(text: &str) -> Result<git_sshripped_ssh_agent_models::AgentWrappedKey> {
344    toml::from_str(text).context("failed to parse agent-wrap TOML")
345}