git_sshripped_repository/
lib.rs1#![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
34pub 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
50pub 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
62pub 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
77pub 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
94pub 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
109pub 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
126pub 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
159fn 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
177pub fn install_git_filters(repo_root: &Path, bin: &str, linked_worktree: bool) -> Result<()> {
193 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#[must_use]
262pub fn agent_wrap_dir(common_dir: &Path) -> PathBuf {
263 common_dir.join("git-sshripped-agent-wrap")
264}
265
266#[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
272pub 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
292pub 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
312pub 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
337pub 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}