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}