agpm_cli/installer/gitignore.rs
1//! Gitignore management utilities for AGPM resources.
2
3use crate::lockfile::{LockFile, LockedResource};
4use crate::utils::fs::atomic_write;
5use crate::utils::normalize_path_for_storage;
6use anyhow::{Context, Result};
7use std::collections::HashSet;
8use std::fs;
9use std::path::Path;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13/// Add a single path to .gitignore atomically
14///
15/// This function adds a single path to the AGPM-managed section of `.gitignore`,
16/// ensuring the file is protected from accidental commits even if subsequent
17/// operations fail. Thread-safe via mutex locking.
18///
19/// # Arguments
20///
21/// * `project_dir` - Project root directory containing `.gitignore`
22/// * `path` - Path to add (relative to project root, forward slashes)
23/// * `lock` - Mutex to synchronize concurrent gitignore updates
24///
25/// # Returns
26///
27/// Returns `Ok(())` if the path was added successfully or was already present.
28pub async fn add_path_to_gitignore(
29 project_dir: &Path,
30 path: &str,
31 lock: &Arc<Mutex<()>>,
32) -> Result<()> {
33 // Acquire lock to ensure thread-safe updates
34 let _guard = lock.lock().await;
35
36 let gitignore_path = project_dir.join(".gitignore");
37
38 // Read existing .gitignore content
39 let mut before_agpm = Vec::new();
40 let mut agpm_paths = std::collections::HashSet::new();
41 let mut after_agpm = Vec::new();
42
43 if gitignore_path.exists() {
44 let content = tokio::fs::read_to_string(&gitignore_path)
45 .await
46 .with_context(|| format!("Failed to read {}", gitignore_path.display()))?;
47
48 let mut in_agpm_section = false;
49 let mut past_agpm_section = false;
50
51 for line in content.lines() {
52 if line == "# AGPM managed entries - do not edit below this line"
53 || line == "# CCPM managed entries - do not edit below this line"
54 {
55 in_agpm_section = true;
56 } else if line == "# End of AGPM managed entries"
57 || line == "# End of CCPM managed entries"
58 {
59 in_agpm_section = false;
60 past_agpm_section = true;
61 } else if in_agpm_section {
62 // Collect existing AGPM paths
63 if !line.is_empty() && !line.starts_with('#') {
64 agpm_paths.insert(line.to_string());
65 }
66 } else if !past_agpm_section {
67 before_agpm.push(line.to_string());
68 } else {
69 after_agpm.push(line.to_string());
70 }
71 }
72 }
73
74 // Add the new path if not already present
75 let normalized_path = normalize_path_for_storage(path);
76 if agpm_paths.contains(&normalized_path) {
77 // Path already exists, no update needed
78 return Ok(());
79 }
80 agpm_paths.insert(normalized_path);
81
82 // Always include private config files
83 agpm_paths.insert("agpm.private.toml".to_string());
84 agpm_paths.insert("agpm.private.lock".to_string());
85
86 // Build new content
87 let mut new_content = String::new();
88
89 // Add header for new files
90 if before_agpm.is_empty() && after_agpm.is_empty() {
91 new_content.push_str("# .gitignore - AGPM managed entries\n");
92 new_content.push_str("# AGPM entries are automatically generated\n");
93 new_content.push('\n');
94 } else {
95 // Preserve content before AGPM section
96 for line in &before_agpm {
97 new_content.push_str(line);
98 new_content.push('\n');
99 }
100 if !before_agpm.is_empty() && !before_agpm.last().unwrap().trim().is_empty() {
101 new_content.push('\n');
102 }
103 }
104
105 // Add AGPM section
106 new_content.push_str("# AGPM managed entries - do not edit below this line\n");
107 let mut sorted_paths: Vec<_> = agpm_paths.into_iter().collect();
108 sorted_paths.sort();
109 for p in sorted_paths {
110 new_content.push_str(&p);
111 new_content.push('\n');
112 }
113 new_content.push_str("# End of AGPM managed entries\n");
114
115 // Preserve content after AGPM section
116 if !after_agpm.is_empty() {
117 new_content.push('\n');
118 for line in &after_agpm {
119 new_content.push_str(line);
120 new_content.push('\n');
121 }
122 }
123
124 // Write atomically
125 atomic_write(&gitignore_path, new_content.as_bytes())
126 .with_context(|| format!("Failed to update {}", gitignore_path.display()))?;
127
128 Ok(())
129}
130
131/// Update .gitignore with all installed resource file paths.
132///
133/// This function updates the project's `.gitignore` file to include all resources
134/// that are installed by AGPM, preventing accidental commits of managed files.
135/// It preserves existing user entries while managing the AGPM section automatically.
136///
137/// # AGPM Section Management
138///
139/// The function maintains a dedicated section in `.gitignore`:
140/// ```text
141/// # AGPM managed entries - do not edit below this line
142/// .claude/agents/example.md
143/// .claude/snippets/shared.md
144/// agpm.private.toml
145/// agpm.private.lock
146/// # End of AGPM managed entries
147/// ```
148///
149/// # Arguments
150///
151/// * `lockfile` - The lockfile containing all installed resources and their paths
152/// * `project_dir` - The project root directory containing the `.gitignore` file
153/// * `enabled` - Whether gitignore management is enabled (can be disabled via config)
154///
155/// # Behavior
156///
157/// - **Creates new file**: If no `.gitignore` exists, creates one with AGPM section
158/// - **Updates existing file**: Preserves user content, adds/replaces AGPM section
159/// - **No-op when disabled**: Returns early if gitignore management is disabled
160/// - **Always included**: Private config files (`agpm.private.toml`, `agpm.private.lock`)
161/// - **Resource types**: Includes agents, snippets, commands, and scripts
162/// - **Excludes**: Hooks and MCP servers (configuration only, not installed as files)
163///
164/// # Preservation of User Content
165///
166/// The function preserves all non-AGPM content:
167/// - Existing entries before AGPM section are kept unchanged
168/// - Existing entries after AGPM section are kept unchanged
169/// - Only the managed section between the markers is replaced
170///
171/// # Migration Support
172///
173/// Supports migration from legacy CCPM (Claude Code Package Manager):
174/// - Recognizes both `# CCPM managed entries` and `# AGPM managed entries`
175/// - Automatically converts to AGPM format on update
176///
177/// # Errors
178///
179/// Returns an error if:
180/// - File cannot be read due to permissions
181/// - File cannot be written due to permissions or disk space
182/// - Project directory doesn't exist
183///
184/// # Examples
185///
186/// ```rust,no_run
187/// use agpm_cli::installer::update_gitignore;
188/// use agpm_cli::lockfile::LockFile;
189/// use std::path::Path;
190///
191/// # async fn example() -> anyhow::Result<()> {
192/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
193/// let project_dir = Path::new(".");
194///
195/// // Update .gitignore with all installed resources
196/// update_gitignore(&lockfile, project_dir, true)?;
197///
198/// println!("Gitignore updated successfully");
199/// # Ok(())
200/// # }
201/// ```
202pub fn update_gitignore(lockfile: &LockFile, project_dir: &Path, enabled: bool) -> Result<()> {
203 if !enabled {
204 // Gitignore management is disabled
205 return Ok(());
206 }
207
208 let gitignore_path = project_dir.join(".gitignore");
209
210 // Collect all installed file paths relative to project root
211 let mut paths_to_ignore = HashSet::new();
212
213 // Helper to add paths from a resource list
214 let mut add_resource_paths = |resources: &[LockedResource]| {
215 for resource in resources {
216 // Skip resources with install=false (they're not written to disk)
217 if resource.install == Some(false) {
218 continue;
219 }
220 if !resource.installed_at.is_empty() {
221 // Use the explicit installed_at path
222 paths_to_ignore.insert(resource.installed_at.clone());
223 }
224 }
225 };
226
227 // Collect paths from all resource types
228 // Skip hooks and MCP servers - they are configured only, not installed as files
229 add_resource_paths(&lockfile.agents);
230 add_resource_paths(&lockfile.snippets);
231 add_resource_paths(&lockfile.commands);
232 add_resource_paths(&lockfile.scripts);
233
234 // Read existing gitignore if it exists
235 let mut before_agpm_section = Vec::new();
236 let mut after_agpm_section = Vec::new();
237
238 if gitignore_path.exists() {
239 let content = fs::read_to_string(&gitignore_path)
240 .with_context(|| format!("Failed to read {}", gitignore_path.display()))?;
241
242 let mut in_agpm_section = false;
243 let mut past_agpm_section = false;
244
245 for line in content.lines() {
246 // Support both AGPM and legacy CCPM markers for migration compatibility
247 if line == "# AGPM managed entries - do not edit below this line"
248 || line == "# CCPM managed entries - do not edit below this line"
249 {
250 in_agpm_section = true;
251 continue;
252 } else if line == "# End of AGPM managed entries"
253 || line == "# End of CCPM managed entries"
254 {
255 in_agpm_section = false;
256 past_agpm_section = true;
257 continue;
258 }
259
260 if !in_agpm_section && !past_agpm_section {
261 // Preserve everything before AGPM section exactly as-is
262 before_agpm_section.push(line.to_string());
263 } else if in_agpm_section {
264 // Skip existing AGPM/CCPM entries (they'll be replaced)
265 // Continue to next line
266 } else {
267 // Preserve everything after AGPM section exactly as-is
268 after_agpm_section.push(line.to_string());
269 }
270 }
271 }
272
273 // Build the new content
274 let mut new_content = String::new();
275
276 // Add everything before AGPM section exactly as it was
277 if !before_agpm_section.is_empty() {
278 for line in &before_agpm_section {
279 new_content.push_str(line);
280 new_content.push('\n');
281 }
282 // Add blank line before AGPM section if the previous content doesn't end with one
283 if !before_agpm_section.is_empty() && !before_agpm_section.last().unwrap().trim().is_empty()
284 {
285 new_content.push('\n');
286 }
287 }
288
289 // Add AGPM managed section
290 new_content.push_str("# AGPM managed entries - do not edit below this line\n");
291
292 // Convert paths to gitignore format (relative to project root)
293 // Sort paths for consistent output
294 let mut sorted_paths: Vec<_> = paths_to_ignore.into_iter().collect();
295 sorted_paths.sort();
296
297 for path in &sorted_paths {
298 // Use paths as-is since gitignore is now at project root
299 let ignore_path = if path.starts_with("./") {
300 // Remove leading ./ if present
301 path.strip_prefix("./").unwrap_or(path).to_string()
302 } else {
303 path.clone()
304 };
305
306 // Normalize to forward slashes for .gitignore (Git expects forward slashes on all platforms)
307 let normalized_path = normalize_path_for_storage(&ignore_path);
308
309 new_content.push_str(&normalized_path);
310 new_content.push('\n');
311 }
312
313 new_content.push_str("# End of AGPM managed entries\n");
314
315 // Add everything after AGPM section exactly as it was
316 if !after_agpm_section.is_empty() {
317 new_content.push('\n');
318 for line in &after_agpm_section {
319 new_content.push_str(line);
320 new_content.push('\n');
321 }
322 }
323
324 // If this is a new file, add a basic header
325 if before_agpm_section.is_empty() && after_agpm_section.is_empty() {
326 let mut default_content = String::new();
327 default_content.push_str("# .gitignore - AGPM managed entries\n");
328 default_content.push_str("# AGPM entries are automatically generated\n");
329 default_content.push('\n');
330 default_content.push_str("# AGPM managed entries - do not edit below this line\n");
331
332 // Add the AGPM paths
333 for path in &sorted_paths {
334 let ignore_path = if path.starts_with("./") {
335 path.strip_prefix("./").unwrap_or(path).to_string()
336 } else {
337 path.clone()
338 };
339 // Normalize to forward slashes for .gitignore (Git expects forward slashes on all platforms)
340 let normalized_path = ignore_path.replace('\\', "/");
341 default_content.push_str(&normalized_path);
342 default_content.push('\n');
343 }
344
345 default_content.push_str("# End of AGPM managed entries\n");
346 new_content = default_content;
347 }
348
349 // Write the updated gitignore
350 atomic_write(&gitignore_path, new_content.as_bytes())
351 .with_context(|| format!("Failed to update {}", gitignore_path.display()))?;
352
353 Ok(())
354}