git_pair/
lib.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub fn get_git_pair_dir() -> Result<PathBuf, String> {
7    let current_dir =
8        env::current_dir().map_err(|e| format!("Error getting current directory: {}", e))?;
9    let git_dir = current_dir.join(".git");
10
11    if !git_dir.exists() {
12        return Err("Not in a git repository. Please run 'git init' first.".to_string());
13    }
14
15    Ok(git_dir.join("git-pair"))
16}
17
18fn get_current_branch() -> Result<String, String> {
19    let output = Command::new("git")
20        .args(["branch", "--show-current"])
21        .output()
22        .map_err(|e| format!("Error running git command: {}", e))?;
23
24    if !output.status.success() {
25        return Err("Failed to get current branch name".to_string());
26    }
27
28    let branch_name = String::from_utf8(output.stdout)
29        .map_err(|e| format!("Error parsing branch name: {}", e))?
30        .trim()
31        .to_string();
32
33    if branch_name.is_empty() {
34        return Err("No branch name found (detached HEAD?)".to_string());
35    }
36
37    Ok(branch_name)
38}
39
40fn get_branch_config_file() -> Result<PathBuf, String> {
41    let git_pair_dir = get_git_pair_dir()?;
42    let branch_name = get_current_branch()?;
43
44    // Sanitize branch name for filename (replace problematic characters)
45    let safe_branch_name = branch_name.replace(['/', '\\', ':'], "_");
46
47    Ok(git_pair_dir.join(format!("config-{}", safe_branch_name)))
48}
49
50// Global roster management functions
51fn get_global_config_dir() -> Result<PathBuf, String> {
52    let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?;
53    let config_dir = PathBuf::from(home_dir).join(".config").join("git-pair");
54    Ok(config_dir)
55}
56
57fn get_global_roster_file() -> Result<PathBuf, String> {
58    // Check for environment variable override (useful for testing)
59    if let Ok(custom_path) = env::var("GIT_PAIR_ROSTER_FILE") {
60        return Ok(PathBuf::from(custom_path));
61    }
62
63    let config_dir = get_global_config_dir()?;
64    Ok(config_dir.join("roster"))
65}
66
67pub fn add_global_coauthor(alias: &str, name: &str, email: &str) -> Result<String, String> {
68    let roster_file = get_global_roster_file()?;
69
70    // Create parent directory if it doesn't exist (handle both default and custom paths)
71    if let Some(parent) = roster_file.parent() {
72        fs::create_dir_all(parent)
73            .map_err(|e| format!("Error creating roster directory: {}", e))?;
74    }
75
76    // Read existing roster or create default content
77    let content = if roster_file.exists() {
78        fs::read_to_string(&roster_file)
79            .map_err(|e| format!("Error reading global roster: {}", e))?
80    } else {
81        "# Global git-pair roster\n# Format: alias|name|email\n".to_string()
82    };
83
84    // Check if alias already exists
85    if content
86        .lines()
87        .any(|line| line.starts_with(&format!("{}|", alias)))
88    {
89        return Err(format!("Alias '{}' already exists in global roster", alias));
90    }
91
92    // Add new entry
93    let new_entry = format!("{}|{}|{}\n", alias, name, email);
94    let new_content = content + &new_entry;
95
96    fs::write(&roster_file, new_content)
97        .map_err(|e| format!("Error writing to global roster: {}", e))?;
98
99    Ok(format!(
100        "Added '{}' ({} <{}>) to global roster",
101        alias, name, email
102    ))
103}
104
105pub fn get_global_roster() -> Result<Vec<(String, String, String)>, String> {
106    let roster_file = get_global_roster_file()?;
107
108    if !roster_file.exists() {
109        return Ok(Vec::new());
110    }
111
112    let content = fs::read_to_string(&roster_file)
113        .map_err(|e| format!("Error reading global roster: {}", e))?;
114
115    let mut roster = Vec::new();
116    for line in content.lines() {
117        if line.starts_with('#') || line.trim().is_empty() {
118            continue;
119        }
120
121        let parts: Vec<&str> = line.split('|').collect();
122        if parts.len() == 3 {
123            roster.push((
124                parts[0].to_string(),
125                parts[1].to_string(),
126                parts[2].to_string(),
127            ));
128        }
129    }
130
131    Ok(roster)
132}
133
134pub fn add_coauthor_from_global(alias: &str) -> Result<String, String> {
135    let roster = get_global_roster()?;
136
137    // Find the alias in the roster
138    if let Some((_, name, email)) = roster.iter().find(|(a, _, _)| a == alias) {
139        // Split name into first and last name for the existing add_coauthor function
140        let name_parts: Vec<&str> = name.split_whitespace().collect();
141        if name_parts.len() >= 2 {
142            let first_name = name_parts[0];
143            let last_name = name_parts[1..].join(" ");
144            add_coauthor(first_name, &last_name, email)
145        } else {
146            // If only one name, use it as first name and empty last name
147            add_coauthor(name, "", email)
148        }
149    } else {
150        Err(format!("Alias '{}' not found in global roster. Use 'git pair list --global' to see available aliases.", alias))
151    }
152}
153
154pub fn init_pair_config() -> Result<String, String> {
155    let _current_dir =
156        env::current_dir().map_err(|e| format!("Error getting current directory: {}", e))?;
157    let git_pair_dir = get_git_pair_dir()?;
158    let branch_name = get_current_branch()?;
159
160    // Create .git/git-pair directory
161    fs::create_dir_all(&git_pair_dir)
162        .map_err(|e| format!("Error creating git-pair directory: {}", e))?;
163
164    // Create branch-specific config file
165    let config_file = get_branch_config_file()?;
166    let default_config = format!(
167        "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
168        branch_name
169    );
170
171    if config_file.exists() {
172        Ok(format!(
173            "git-pair already initialized for branch '{}'",
174            branch_name
175        ))
176    } else {
177        fs::write(&config_file, default_config)
178            .map_err(|e| format!("Error creating config file: {}", e))?;
179        Ok(format!(
180            "Successfully initialized git-pair for branch '{}'!\nConfiguration file created at: {}",
181            branch_name,
182            config_file.display()
183        ))
184    }
185}
186
187pub fn add_coauthor(name: &str, surname: &str, email: &str) -> Result<String, String> {
188    let config_file = get_branch_config_file()?;
189    let branch_name = get_current_branch()?;
190
191    // Check if git-pair is initialized for this branch
192    if !config_file.exists() {
193        return Err(format!(
194            "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
195            branch_name
196        ));
197    }
198
199    // Read existing config
200    let existing_content = fs::read_to_string(&config_file)
201        .map_err(|e| format!("Error reading config file: {}", e))?;
202
203    // Create the co-author entry
204    let full_name = format!("{} {}", name, surname);
205    let coauthor_line = format!("Co-authored-by: {} <{}>\n", full_name, email);
206
207    // Check if this co-author already exists
208    if existing_content.contains(coauthor_line.trim()) {
209        return Ok(format!(
210            "Co-author '{}' <{}> already exists on branch '{}'",
211            full_name, email, branch_name
212        ));
213    }
214
215    // Append the new co-author
216    let new_content = existing_content + &coauthor_line;
217
218    fs::write(&config_file, new_content)
219        .map_err(|e| format!("Error writing to config file: {}", e))?;
220
221    update_commit_template()?;
222    Ok(format!(
223        "Added co-author: {} <{}> to branch '{}'",
224        full_name, email, branch_name
225    ))
226}
227
228pub fn update_commit_template() -> Result<(), String> {
229    let config_file = get_branch_config_file()?;
230
231    // Read the config file to get co-authors
232    let config_content = fs::read_to_string(&config_file)
233        .map_err(|e| format!("Error reading config file: {}", e))?;
234
235    // Extract co-author lines
236    let coauthor_lines: Vec<&str> = config_content
237        .lines()
238        .filter(|line| line.starts_with("Co-authored-by:"))
239        .collect();
240
241    if coauthor_lines.is_empty() {
242        // No co-authors, remove the hook
243        remove_git_hook()?;
244    } else {
245        // Install or update the hook with current co-authors
246        let current_dir =
247            env::current_dir().map_err(|e| format!("Error getting current directory: {}", e))?;
248        install_git_hook_in(&current_dir)?;
249    }
250
251    Ok(())
252}
253
254fn remove_git_hook_in(working_dir: &Path) -> Result<(), String> {
255    let hook_file = working_dir
256        .join(".git")
257        .join("hooks")
258        .join("prepare-commit-msg");
259
260    if hook_file.exists() {
261        let hook_content = fs::read_to_string(&hook_file)
262            .map_err(|e| format!("Error reading hook file: {}", e))?;
263
264        // Check if our section exists
265        if let Some(new_content) = remove_git_pair_section(&hook_content) {
266            if is_effectively_empty(&new_content) {
267                // If only whitespace/comments/shebang remain, remove the entire file
268                fs::remove_file(&hook_file)
269                    .map_err(|e| format!("Error removing git hook: {}", e))?;
270            } else {
271                // Write back the content without our section
272                fs::write(&hook_file, new_content)
273                    .map_err(|e| format!("Error updating git hook: {}", e))?;
274            }
275        }
276    }
277
278    Ok(())
279}
280
281fn remove_git_hook() -> Result<(), String> {
282    let current_dir =
283        env::current_dir().map_err(|e| format!("Error getting current directory: {}", e))?;
284    remove_git_hook_in(&current_dir)
285}
286
287pub fn remove_coauthor(identifier: &str) -> Result<String, String> {
288    let config_file = get_branch_config_file()?;
289    let branch_name = get_current_branch()?;
290
291    // Check if git-pair is initialized for this branch
292    if !config_file.exists() {
293        return Err(format!(
294            "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
295            branch_name
296        ));
297    }
298
299    // Read existing config
300    let existing_content = fs::read_to_string(&config_file)
301        .map_err(|e| format!("Error reading config file: {}", e))?;
302
303    // Get current co-authors
304    let mut coauthor_lines: Vec<String> = existing_content
305        .lines()
306        .filter(|line| line.starts_with("Co-authored-by:"))
307        .map(|line| line.to_string())
308        .collect();
309
310    // Store original count for comparison
311    let original_count = coauthor_lines.len();
312
313    // Try to match by different criteria
314    coauthor_lines.retain(|line| !matches_coauthor(line, identifier));
315
316    if coauthor_lines.len() == original_count {
317        // No co-author was removed, check if it might be a global alias
318        if let Ok(roster) = get_global_roster() {
319            if let Some((_, name, email)) = roster.iter().find(|(alias, _, _)| alias == identifier)
320            {
321                // Try to remove by the actual name/email from the global roster
322                let full_name_pattern = name;
323                let email_pattern = email;
324
325                coauthor_lines.retain(|line| {
326                    !line.contains(full_name_pattern) && !line.contains(email_pattern)
327                });
328
329                if coauthor_lines.len() == original_count {
330                    return Err(format!(
331                        "Co-author matching alias '{}' ({} <{}>) not found on branch '{}'",
332                        identifier, name, email, branch_name
333                    ));
334                }
335            } else {
336                return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name));
337            }
338        } else {
339            return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name));
340        }
341    }
342
343    // Reconstruct the config file content
344    let mut new_content = String::new();
345
346    // Add header
347    new_content.push_str(&format!(
348        "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
349        branch_name
350    ));
351
352    // Add remaining co-authors
353    for coauthor in &coauthor_lines {
354        new_content.push_str(coauthor);
355        new_content.push('\n');
356    }
357
358    // Write back the updated content
359    fs::write(&config_file, new_content)
360        .map_err(|e| format!("Error writing to config file: {}", e))?;
361
362    // Update the commit template
363    update_commit_template()?;
364
365    let removed_count = original_count - coauthor_lines.len();
366    if removed_count == 1 {
367        Ok(format!(
368            "Removed 1 co-author matching '{}' from branch '{}'",
369            identifier, branch_name
370        ))
371    } else {
372        Ok(format!(
373            "Removed {} co-authors matching '{}' from branch '{}'",
374            removed_count, identifier, branch_name
375        ))
376    }
377}
378
379fn matches_coauthor(coauthor_line: &str, identifier: &str) -> bool {
380    // Match by full name (case-insensitive)
381    if coauthor_line
382        .to_lowercase()
383        .contains(&identifier.to_lowercase())
384    {
385        return true;
386    }
387
388    // Match by email
389    if coauthor_line.contains(identifier) {
390        return true;
391    }
392
393    false
394}
395
396pub fn clear_coauthors() -> Result<String, String> {
397    let config_file = get_branch_config_file()?;
398    let branch_name = get_current_branch()?;
399
400    // Check if git-pair is initialized for this branch
401    if !config_file.exists() {
402        return Err(format!(
403            "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
404            branch_name
405        ));
406    }
407
408    // Reset config file to default content
409    let default_config = format!(
410        "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
411        branch_name
412    );
413    fs::write(&config_file, default_config)
414        .map_err(|e| format!("Error clearing config file: {}", e))?;
415
416    // Remove git hook
417    remove_git_hook()?;
418
419    Ok(format!(
420        "Cleared all co-authors for branch '{}' and uninstalled git hook",
421        branch_name
422    ))
423}
424
425pub fn get_coauthors() -> Result<Vec<String>, String> {
426    let config_file = get_branch_config_file()?;
427    let branch_name = get_current_branch()?;
428
429    if !config_file.exists() {
430        return Err(format!(
431            "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
432            branch_name
433        ));
434    }
435
436    let config_content = fs::read_to_string(&config_file)
437        .map_err(|e| format!("Error reading config file: {}", e))?;
438
439    let coauthors: Vec<String> = config_content
440        .lines()
441        .filter(|line| line.starts_with("Co-authored-by:"))
442        .map(|line| line.to_string())
443        .collect();
444
445    Ok(coauthors)
446}
447
448// Helper functions for hook management
449
450/// Checks if hook content is effectively empty (only shebang, whitespace, or comments)
451fn is_effectively_empty(content: &str) -> bool {
452    for line in content.lines() {
453        let trimmed = line.trim();
454        if !trimmed.is_empty() && !trimmed.starts_with('#') {
455            return false;
456        }
457    }
458    true
459}
460
461/// Merges git-pair section into existing hook content
462fn merge_git_pair_section(
463    existing_content: &str,
464    git_pair_section: &str,
465) -> Result<String, String> {
466    const BEGIN_MARKER: &str = "# BEGIN git-pair";
467    const END_MARKER: &str = "# END git-pair";
468
469    // Check if git-pair section already exists
470    if let Some(begin_pos) = existing_content.find(BEGIN_MARKER) {
471        if let Some(end_pos) = existing_content.find(END_MARKER) {
472            // Replace existing git-pair section
473            let before = &existing_content[..begin_pos];
474            let after = &existing_content[end_pos + END_MARKER.len()..];
475
476            // Remove trailing newline from 'before' if it exists, and ensure proper spacing
477            let before_trimmed = before.trim_end();
478            let after_trimmed = after.trim_start();
479
480            let mut result = String::new();
481
482            // Add shebang if this is a new file and before section is empty
483            if before_trimmed.is_empty() && !existing_content.starts_with("#!") {
484                result.push_str("#!/bin/sh\n");
485            }
486
487            if !before_trimmed.is_empty() {
488                result.push_str(before_trimmed);
489                result.push('\n');
490            }
491
492            result.push_str(git_pair_section);
493
494            if !after_trimmed.is_empty() {
495                result.push('\n');
496                result.push_str(after_trimmed);
497            }
498
499            Ok(result)
500        } else {
501            Err("Found BEGIN marker but no END marker in existing hook".to_string())
502        }
503    } else {
504        // Append git-pair section to existing content
505        let mut result = String::new();
506
507        if existing_content.trim().is_empty() {
508            // New file, add shebang
509            result.push_str("#!/bin/sh\n");
510            result.push_str(git_pair_section);
511        } else {
512            // Existing content, append our section
513            result.push_str(existing_content.trim_end());
514            result.push_str("\n\n");
515            result.push_str(git_pair_section);
516        }
517
518        Ok(result)
519    }
520}
521
522/// Removes git-pair section from hook content, returns None if no section found
523fn remove_git_pair_section(content: &str) -> Option<String> {
524    const BEGIN_MARKER: &str = "# BEGIN git-pair";
525    const END_MARKER: &str = "# END git-pair";
526
527    if let Some(begin_pos) = content.find(BEGIN_MARKER) {
528        if let Some(end_pos) = content.find(END_MARKER) {
529            let before = &content[..begin_pos];
530            let after = &content[end_pos + END_MARKER.len()..];
531
532            // Clean up spacing - remove extra newlines around the removed section
533            let before_trimmed = before.trim_end();
534            let after_trimmed = after.trim_start();
535
536            let mut result = String::new();
537
538            if !before_trimmed.is_empty() {
539                result.push_str(before_trimmed);
540                if !after_trimmed.is_empty() {
541                    result.push('\n');
542                }
543            }
544
545            if !after_trimmed.is_empty() {
546                result.push_str(after_trimmed);
547            }
548
549            Some(result)
550        } else {
551            // Found BEGIN but no END, don't modify
552            None
553        }
554    } else {
555        // No git-pair section found
556        None
557    }
558}
559
560fn install_git_hook_in(working_dir: &Path) -> Result<(), String> {
561    let hooks_dir = working_dir.join(".git").join("hooks");
562    let hook_file = hooks_dir.join("prepare-commit-msg");
563
564    // Create hooks directory if it doesn't exist
565    fs::create_dir_all(&hooks_dir).map_err(|e| format!("Error creating hooks directory: {}", e))?;
566
567    // Read existing hook content if it exists
568    let existing_content = if hook_file.exists() {
569        fs::read_to_string(&hook_file)
570            .map_err(|e| format!("Error reading existing hook file: {}", e))?
571    } else {
572        String::new()
573    };
574
575    // Generate our git-pair hook section
576    let git_pair_section = r#"# BEGIN git-pair
577# git-pair hook to automatically add co-authors
578
579COMMIT_MSG_FILE=$1
580COMMIT_SOURCE=$2
581
582# Only add co-authors for regular commits (not merges, rebases, etc.)
583if [ -z "$COMMIT_SOURCE" ] || [ "$COMMIT_SOURCE" = "message" ]; then
584  # Check if co-authors are already present
585  if ! grep -q "Co-authored-by:" "$COMMIT_MSG_FILE"; then
586    # Get current branch and config file
587    CURRENT_BRANCH=$(git branch --show-current)
588    SAFE_BRANCH=$(echo "$CURRENT_BRANCH" | sed 's/[/\\:]/_/g')
589    CONFIG_FILE=".git/git-pair/config-$SAFE_BRANCH"
590
591    # Add co-authors from branch-specific config if it exists
592    if [ -f "$CONFIG_FILE" ]; then
593      COAUTHORS=$(grep '^Co-authored-by:' "$CONFIG_FILE")
594      if [ -n "$COAUTHORS" ]; then
595        echo "" >> "$COMMIT_MSG_FILE"
596        echo "$COAUTHORS" >> "$COMMIT_MSG_FILE"
597      fi
598    fi
599  fi
600fi
601# END git-pair"#;
602
603    // Create the new hook content
604    let new_content = merge_git_pair_section(&existing_content, git_pair_section)?;
605
606    // Write the hook file
607    fs::write(&hook_file, new_content).map_err(|e| format!("Error writing git hook: {}", e))?;
608
609    // Make the hook executable
610    #[cfg(unix)]
611    {
612        use std::os::unix::fs::PermissionsExt;
613        let mut perms = fs::metadata(&hook_file)
614            .map_err(|e| format!("Error getting hook file permissions: {}", e))?
615            .permissions();
616        perms.set_mode(0o755);
617        fs::set_permissions(&hook_file, perms)
618            .map_err(|e| format!("Error setting hook file permissions: {}", e))?;
619    }
620
621    Ok(())
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use std::env;
628    use std::fs;
629    use std::path::{Path, PathBuf};
630    use std::process::Command;
631    use std::sync::Mutex;
632    use std::time::{SystemTime, UNIX_EPOCH};
633
634    // Import the helper function for tests
635    use super::matches_coauthor;
636
637    // Mutex to ensure global roster tests don't interfere with each other
638    static GLOBAL_ROSTER_TEST_LOCK: Mutex<()> = Mutex::new(());
639
640    // Simple RAII wrapper for temporary directories
641    struct TempDir {
642        path: PathBuf,
643    }
644
645    impl TempDir {
646        fn new() -> std::io::Result<Self> {
647            let timestamp = SystemTime::now()
648                .duration_since(UNIX_EPOCH)
649                .unwrap()
650                .as_nanos();
651
652            let mut temp_path = env::temp_dir();
653            temp_path.push(format!(
654                "git-pair-test-{}-{}",
655                std::process::id(),
656                timestamp
657            ));
658
659            fs::create_dir_all(&temp_path)?;
660            Ok(TempDir { path: temp_path })
661        }
662
663        fn path(&self) -> &Path {
664            &self.path
665        }
666    }
667
668    impl Drop for TempDir {
669        fn drop(&mut self) {
670            let _ = fs::remove_dir_all(&self.path);
671        }
672    }
673
674    // Simple temporary file helper
675    fn create_temp_file() -> std::io::Result<PathBuf> {
676        let timestamp = SystemTime::now()
677            .duration_since(UNIX_EPOCH)
678            .unwrap()
679            .as_nanos();
680
681        let mut temp_path = env::temp_dir();
682        temp_path.push(format!(
683            "git-pair-test-{}-{}",
684            std::process::id(),
685            timestamp
686        ));
687
688        // Create empty file
689        fs::write(&temp_path, "")?;
690        Ok(temp_path)
691    }
692
693    // Test helper functions that work with a specific directory instead of changing global cwd
694    fn get_git_pair_dir_in(working_dir: &Path) -> Result<PathBuf, String> {
695        let git_dir = working_dir.join(".git");
696
697        if !git_dir.exists() {
698            return Err("Not in a git repository. Please run 'git init' first.".to_string());
699        }
700
701        Ok(git_dir.join("git-pair"))
702    }
703
704    fn get_current_branch_in(working_dir: &Path) -> Result<String, String> {
705        let output = Command::new("git")
706            .args(["branch", "--show-current"])
707            .current_dir(working_dir)
708            .output()
709            .map_err(|e| format!("Error running git command: {}", e))?;
710
711        if !output.status.success() {
712            return Err("Error getting current branch".to_string());
713        }
714
715        let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
716        if branch.is_empty() {
717            return Err("No current branch found".to_string());
718        }
719
720        Ok(branch)
721    }
722
723    fn get_branch_config_file_in(working_dir: &Path) -> Result<PathBuf, String> {
724        let git_pair_dir = get_git_pair_dir_in(working_dir)?;
725        let branch_name = get_current_branch_in(working_dir)?;
726
727        // Sanitize branch name for filename (replace problematic characters)
728        let safe_branch_name = branch_name.replace(['/', '\\', ':'], "_");
729
730        Ok(git_pair_dir.join(format!("config-{}", safe_branch_name)))
731    }
732
733    fn init_pair_config_in(working_dir: &Path) -> Result<String, String> {
734        let git_pair_dir = get_git_pair_dir_in(working_dir)?;
735        let branch_name = get_current_branch_in(working_dir)?;
736
737        // Create .git/git-pair directory
738        fs::create_dir_all(&git_pair_dir)
739            .map_err(|e| format!("Error creating git-pair directory: {}", e))?;
740
741        // Create branch-specific config file
742        let config_file = get_branch_config_file_in(working_dir)?;
743        let default_config = format!(
744            "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
745            branch_name
746        );
747
748        if config_file.exists() {
749            Ok(format!(
750                "git-pair already initialized for branch '{}'",
751                branch_name
752            ))
753        } else {
754            fs::write(&config_file, default_config)
755                .map_err(|e| format!("Error creating config file: {}", e))?;
756            Ok(format!("Successfully initialized git-pair for branch '{}'!\nConfiguration file created at: {}", branch_name, config_file.display()))
757        }
758    }
759
760    fn add_coauthor_in(
761        working_dir: &Path,
762        name: &str,
763        surname: &str,
764        email: &str,
765    ) -> Result<String, String> {
766        let config_file = get_branch_config_file_in(working_dir)?;
767        let branch_name = get_current_branch_in(working_dir)?;
768
769        // Check if git-pair is initialized for this branch
770        if !config_file.exists() {
771            return Err(format!(
772                "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
773                branch_name
774            ));
775        }
776
777        // Read existing config
778        let existing_content = fs::read_to_string(&config_file)
779            .map_err(|e| format!("Error reading config file: {}", e))?;
780
781        // Create the co-author entry
782        let full_name = format!("{} {}", name, surname);
783        let coauthor_line = format!("Co-authored-by: {} <{}>\n", full_name, email);
784
785        // Check if this co-author already exists
786        if existing_content.contains(coauthor_line.trim()) {
787            return Ok(format!(
788                "Co-author '{}' <{}> already exists on branch '{}'",
789                full_name, email, branch_name
790            ));
791        }
792
793        // Append the new co-author
794        let new_content = existing_content + &coauthor_line;
795
796        fs::write(&config_file, new_content)
797            .map_err(|e| format!("Error writing to config file: {}", e))?;
798
799        // Install/update hook
800        install_git_hook_in(working_dir)?;
801        Ok(format!(
802            "Added co-author: {} <{}> to branch '{}'",
803            full_name, email, branch_name
804        ))
805    }
806
807    fn get_coauthors_in(working_dir: &Path) -> Result<Vec<String>, String> {
808        let config_file = get_branch_config_file_in(working_dir)?;
809        let branch_name = get_current_branch_in(working_dir)?;
810
811        if !config_file.exists() {
812            return Err(format!(
813                "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
814                branch_name
815            ));
816        }
817
818        let content = fs::read_to_string(&config_file)
819            .map_err(|e| format!("Error reading config file: {}", e))?;
820
821        let coauthors: Vec<String> = content
822            .lines()
823            .filter(|line| line.starts_with("Co-authored-by:"))
824            .map(|line| line.to_string())
825            .collect();
826
827        Ok(coauthors)
828    }
829
830    fn remove_coauthor_in(working_dir: &Path, identifier: &str) -> Result<String, String> {
831        let config_file = get_branch_config_file_in(working_dir)?;
832        let branch_name = get_current_branch_in(working_dir)?;
833
834        // Check if git-pair is initialized for this branch
835        if !config_file.exists() {
836            return Err(format!(
837                "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
838                branch_name
839            ));
840        }
841
842        // Read existing config
843        let existing_content = fs::read_to_string(&config_file)
844            .map_err(|e| format!("Error reading config file: {}", e))?;
845
846        // Get current co-authors
847        let mut coauthor_lines: Vec<String> = existing_content
848            .lines()
849            .filter(|line| line.starts_with("Co-authored-by:"))
850            .map(|line| line.to_string())
851            .collect();
852
853        // Store original count for comparison
854        let original_count = coauthor_lines.len();
855
856        // Try to match by different criteria
857        coauthor_lines.retain(|line| !matches_coauthor(line, identifier));
858
859        if coauthor_lines.len() == original_count {
860            // No co-author was removed, check if it might be a global alias
861            if let Ok(roster) = get_global_roster() {
862                if let Some((_, name, email)) =
863                    roster.iter().find(|(alias, _, _)| alias == identifier)
864                {
865                    // Try to remove by the actual name/email from the global roster
866                    let full_name_pattern = name;
867                    let email_pattern = email;
868
869                    coauthor_lines.retain(|line| {
870                        !line.contains(full_name_pattern) && !line.contains(email_pattern)
871                    });
872
873                    if coauthor_lines.len() == original_count {
874                        return Err(format!(
875                            "Co-author matching alias '{}' ({} <{}>) not found on branch '{}'",
876                            identifier, name, email, branch_name
877                        ));
878                    }
879                } else {
880                    return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name));
881                }
882            } else {
883                return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name));
884            }
885        }
886
887        // Reconstruct the config file content
888        let mut new_content = String::new();
889
890        // Add header
891        new_content.push_str(&format!(
892            "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
893            branch_name
894        ));
895
896        // Add remaining co-authors
897        for coauthor in &coauthor_lines {
898            new_content.push_str(coauthor);
899            new_content.push('\n');
900        }
901
902        // Write back the updated content
903        fs::write(&config_file, new_content)
904            .map_err(|e| format!("Error writing to config file: {}", e))?;
905
906        // Update git hook
907        if coauthor_lines.is_empty() {
908            remove_git_hook_in(working_dir)?;
909        } else {
910            install_git_hook_in(working_dir)?;
911        }
912
913        let removed_count = original_count - coauthor_lines.len();
914        if removed_count == 1 {
915            Ok(format!(
916                "Removed 1 co-author matching '{}' from branch '{}'",
917                identifier, branch_name
918            ))
919        } else {
920            Ok(format!(
921                "Removed {} co-authors matching '{}' from branch '{}'",
922                removed_count, identifier, branch_name
923            ))
924        }
925    }
926
927    fn clear_coauthors_in(working_dir: &Path) -> Result<String, String> {
928        let config_file = get_branch_config_file_in(working_dir)?;
929        let branch_name = get_current_branch_in(working_dir)?;
930
931        // Check if git-pair is initialized for this branch
932        if !config_file.exists() {
933            return Err(format!(
934                "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
935                branch_name
936            ));
937        }
938
939        // Reset config file to default content
940        let default_config = format!(
941            "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n",
942            branch_name
943        );
944        fs::write(&config_file, default_config)
945            .map_err(|e| format!("Error clearing config file: {}", e))?;
946
947        // Remove git hook
948        remove_git_hook_in(working_dir)?;
949
950        Ok(format!(
951            "Cleared all co-authors for branch '{}' and uninstalled git hook",
952            branch_name
953        ))
954    }
955
956    fn add_coauthor_from_global_in(working_dir: &Path, alias: &str) -> Result<String, String> {
957        // Check if git-pair is initialized
958        let config_file = get_branch_config_file_in(working_dir)?;
959        let branch_name = get_current_branch_in(working_dir)?;
960
961        if !config_file.exists() {
962            return Err(format!(
963                "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.",
964                branch_name
965            ));
966        }
967
968        // Get from global roster
969        let roster = get_global_roster()?;
970        let (_, name, email) = roster
971            .iter()
972            .find(|(a, _, _)| a == alias)
973            .ok_or_else(|| format!("Alias '{}' not found in global roster", alias))?;
974
975        // Split name into first and last name
976        let name_parts: Vec<&str> = name.split_whitespace().collect();
977        let first_name = name_parts.first().map_or("", |v| v).to_string();
978        let last_name = if name_parts.len() > 1 {
979            name_parts[1..].join(" ")
980        } else {
981            String::new()
982        };
983
984        // Add the coauthor
985        add_coauthor_in(working_dir, &first_name, &last_name, email)
986    }
987
988    // Test helper to create a temporary git repository without changing global cwd
989    fn setup_test_repo() -> std::io::Result<TempDir> {
990        use std::process::Command;
991        let temp_dir = TempDir::new()?;
992        let repo_path = temp_dir.path();
993
994        // Initialize git repo in the temp directory (without changing global cwd)
995        Command::new("git")
996            .args(["init"])
997            .current_dir(repo_path)
998            .output()?;
999
1000        // Configure git user (required for commits)
1001        Command::new("git")
1002            .args(["config", "user.name", "Test User"])
1003            .current_dir(repo_path)
1004            .output()?;
1005
1006        Command::new("git")
1007            .args(["config", "user.email", "test@example.com"])
1008            .current_dir(repo_path)
1009            .output()?;
1010
1011        Ok(temp_dir)
1012    }
1013
1014    #[test]
1015    fn test_init_pair_config_success() {
1016        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1017
1018        let result = init_pair_config_in(temp_dir.path()).expect("Init should succeed");
1019        assert!(result.contains("Successfully initialized git-pair for branch"));
1020
1021        // Check that files were created
1022        assert!(temp_dir.path().join(".git/git-pair").exists());
1023
1024        // Check branch-specific config file exists (should be config-main for default branch)
1025        let branch_config =
1026            get_branch_config_file_in(temp_dir.path()).expect("Should get branch config file");
1027        assert!(branch_config.exists());
1028
1029        // Check config file content
1030        let config_content = fs::read_to_string(&branch_config).expect("Config file should exist");
1031        assert!(config_content.contains("# git-pair configuration file for branch"));
1032    }
1033
1034    #[test]
1035    fn test_init_pair_config_already_initialized() {
1036        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1037
1038        // Initialize once
1039        init_pair_config_in(temp_dir.path()).expect("First init should succeed");
1040
1041        // Initialize again
1042        let result = init_pair_config_in(temp_dir.path()).expect("Second init should succeed");
1043        assert!(result.contains("git-pair already initialized"));
1044    }
1045
1046    #[test]
1047    fn test_init_pair_config_not_git_repo() {
1048        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1049
1050        let result = init_pair_config_in(temp_dir.path());
1051        assert!(result.is_err());
1052        assert!(result.unwrap_err().contains("Not in a git repository"));
1053    }
1054
1055    #[test]
1056    fn test_add_coauthor_success() {
1057        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1058        let test_dir = temp_dir.path();
1059        init_pair_config_in(test_dir).expect("Init should succeed");
1060
1061        let result = add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1062            .expect("Add should succeed");
1063        assert!(result.contains("Added co-author: John Doe"));
1064
1065        // Check branch-specific config file was updated
1066        let branch_name = get_current_branch_in(test_dir).expect("Should get current branch");
1067        let config_dir = test_dir.join(".git/git-pair");
1068        let config_file = config_dir.join(format!("config-{}", branch_name));
1069        let config_content = fs::read_to_string(&config_file).expect("Config file should exist");
1070        assert!(config_content.contains("Co-authored-by: John Doe <john.doe@example.com>"));
1071
1072        // Check git hook was installed
1073        assert!(test_dir.join(".git/hooks/prepare-commit-msg").exists());
1074    }
1075
1076    #[test]
1077    fn test_add_coauthor_duplicate() {
1078        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1079        let test_dir = temp_dir.path();
1080        init_pair_config_in(test_dir).expect("Init should succeed");
1081
1082        // Add first time
1083        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1084            .expect("First add should succeed");
1085
1086        // Add same person again
1087        let result = add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1088            .expect("Duplicate add should succeed");
1089        assert!(result.contains("already exists"));
1090    }
1091
1092    #[test]
1093    fn test_add_coauthor_not_initialized() {
1094        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1095        let test_dir = temp_dir.path();
1096
1097        let result = add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com");
1098        assert!(result.is_err());
1099        assert!(result.unwrap_err().contains("git-pair not initialized"));
1100    }
1101
1102    #[test]
1103    fn test_multiple_coauthors() {
1104        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1105        let test_dir = temp_dir.path();
1106        init_pair_config_in(test_dir).expect("Init should succeed");
1107
1108        // Add multiple co-authors
1109        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1110            .expect("First add should succeed");
1111        add_coauthor_in(test_dir, "Jane", "Smith", "jane.smith@example.com")
1112            .expect("Second add should succeed");
1113
1114        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1115        assert_eq!(coauthors.len(), 2);
1116        assert!(coauthors.iter().any(|c| c.contains("John Doe")));
1117        assert!(coauthors.iter().any(|c| c.contains("Jane Smith")));
1118    }
1119
1120    #[test]
1121    fn test_clear_coauthors_success() {
1122        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1123        let test_dir = temp_dir.path();
1124        init_pair_config_in(test_dir).expect("Init should succeed");
1125        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1126            .expect("Add should succeed");
1127
1128        let result = clear_coauthors_in(test_dir).expect("Clear should succeed");
1129        assert!(result.contains("Cleared all co-authors"));
1130
1131        // Check that co-authors were cleared
1132        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1133        assert!(coauthors.is_empty());
1134
1135        // Check that git hook was removed
1136        assert!(!test_dir.join(".git/hooks/prepare-commit-msg").exists());
1137    }
1138
1139    #[test]
1140    fn test_clear_coauthors_not_initialized() {
1141        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1142        let test_dir = temp_dir.path();
1143
1144        let result = clear_coauthors_in(test_dir);
1145        assert!(result.is_err());
1146        assert!(result.unwrap_err().contains("git-pair not initialized"));
1147    }
1148
1149    #[test]
1150    fn test_get_coauthors_empty() {
1151        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1152        let test_dir = temp_dir.path();
1153        init_pair_config_in(test_dir).expect("Init should succeed");
1154
1155        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1156        assert!(coauthors.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_get_coauthors_not_initialized() {
1161        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1162        let test_dir = temp_dir.path();
1163
1164        let result = get_coauthors_in(test_dir);
1165        assert!(result.is_err());
1166        assert!(result.unwrap_err().contains("git-pair not initialized"));
1167    }
1168
1169    #[test]
1170    fn test_git_hook_functionality() {
1171        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1172        let test_dir = temp_dir.path();
1173        init_pair_config_in(test_dir).expect("Init should succeed");
1174        add_coauthor_in(test_dir, "Alice", "Johnson", "alice@example.com")
1175            .expect("Add should succeed");
1176
1177        // Create a test file and commit with -m flag
1178        let test_file = test_dir.join("test.txt");
1179        fs::write(&test_file, "test content").expect("Should write test file");
1180
1181        Command::new("git")
1182            .args(["add", "test.txt"])
1183            .current_dir(test_dir)
1184            .output()
1185            .expect("Git add should succeed");
1186
1187        let output = Command::new("git")
1188            .args(["commit", "-m", "Test commit message"])
1189            .current_dir(test_dir)
1190            .output()
1191            .expect("Git commit should succeed");
1192
1193        assert!(output.status.success());
1194
1195        // Check that the commit message includes co-author
1196        let log_output = Command::new("git")
1197            .args(["log", "--pretty=format:%B", "-1"])
1198            .current_dir(test_dir)
1199            .output()
1200            .expect("Git log should succeed");
1201
1202        let commit_message =
1203            String::from_utf8(log_output.stdout).expect("Log output should be valid UTF-8");
1204        assert!(commit_message.contains("Test commit message"));
1205        assert!(commit_message.contains("Co-authored-by: Alice Johnson <alice@example.com>"));
1206    }
1207
1208    #[test]
1209    fn test_git_config_integration() {
1210        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1211        let test_dir = temp_dir.path();
1212
1213        init_pair_config_in(test_dir).expect("Init should succeed");
1214        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1215            .expect("Add should succeed");
1216
1217        // Check that git hook was installed
1218        assert!(test_dir.join(".git/hooks/prepare-commit-msg").exists());
1219
1220        let hook_content = fs::read_to_string(test_dir.join(".git/hooks/prepare-commit-msg"))
1221            .expect("Hook file should exist");
1222        assert!(hook_content.contains("git-pair hook"));
1223        // With per-branch config, co-author names are read dynamically from config files
1224        // so they won't be hard-coded in the hook
1225        assert!(hook_content.contains("CONFIG_FILE"));
1226        assert!(hook_content.contains("grep '^Co-authored-by:'"));
1227
1228        // Check that the branch-specific config file contains the co-author
1229        let branch_config =
1230            get_branch_config_file_in(test_dir).expect("Should get branch config file");
1231        let config_content = fs::read_to_string(&branch_config).expect("Config file should exist");
1232        assert!(config_content.contains("John Doe"));
1233
1234        // Clear and check hook was removed
1235        clear_coauthors_in(test_dir).expect("Clear should succeed");
1236
1237        // Hook should be removed
1238        assert!(!test_dir.join(".git/hooks/prepare-commit-msg").exists());
1239    }
1240
1241    #[test]
1242    fn test_global_roster_add_and_list() {
1243        let _lock = GLOBAL_ROSTER_TEST_LOCK.lock().unwrap();
1244
1245        // Use a temporary roster file for testing
1246        let temp_path = create_temp_file().expect("Failed to create temp file");
1247        env::set_var("GIT_PAIR_ROSTER_FILE", temp_path.to_str().unwrap());
1248
1249        // Test adding to global roster
1250        let result = add_global_coauthor("alice", "Alice Johnson", "alice@example.com")
1251            .expect("Should add to global roster");
1252        assert!(result.contains("Added 'alice'"));
1253
1254        // Test listing global roster
1255        let roster = get_global_roster().expect("Should get global roster");
1256        assert_eq!(roster.len(), 1);
1257        assert_eq!(
1258            roster[0],
1259            (
1260                "alice".to_string(),
1261                "Alice Johnson".to_string(),
1262                "alice@example.com".to_string()
1263            )
1264        );
1265
1266        // Test duplicate alias
1267        let result = add_global_coauthor("alice", "Alice Smith", "alice.smith@example.com");
1268        assert!(result.is_err());
1269        assert!(result.unwrap_err().contains("already exists"));
1270
1271        // Clean up
1272        env::remove_var("GIT_PAIR_ROSTER_FILE");
1273    }
1274
1275    #[test]
1276    fn test_add_coauthor_from_global() {
1277        let _lock = GLOBAL_ROSTER_TEST_LOCK.lock().unwrap();
1278
1279        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1280        let test_dir = temp_dir.path();
1281        let temp_path = create_temp_file().expect("Failed to create temp file");
1282        env::set_var("GIT_PAIR_ROSTER_FILE", temp_path.to_str().unwrap());
1283
1284        // Initialize branch config
1285        init_pair_config_in(test_dir).expect("Init should succeed");
1286
1287        // Add to global roster
1288        add_global_coauthor("bob", "Bob Wilson", "bob@example.com")
1289            .expect("Should add to global roster");
1290
1291        // Test adding from global roster
1292        let result =
1293            add_coauthor_from_global_in(test_dir, "bob").expect("Should add from global roster");
1294        assert!(result.contains("Added co-author: Bob Wilson"));
1295
1296        // Verify it was added to branch config
1297        let coauthors = get_coauthors_in(test_dir).expect("Should get coauthors");
1298        assert_eq!(coauthors.len(), 1);
1299        assert!(coauthors[0].contains("Bob Wilson"));
1300
1301        // Test non-existent alias
1302        let result = add_coauthor_from_global_in(test_dir, "charlie");
1303        assert!(result.is_err());
1304        assert!(result.unwrap_err().contains("not found in global roster"));
1305
1306        // Clean up
1307        env::remove_var("GIT_PAIR_ROSTER_FILE");
1308    }
1309
1310    #[test]
1311    fn test_global_roster_empty() {
1312        let _lock = GLOBAL_ROSTER_TEST_LOCK.lock().unwrap();
1313
1314        let temp_path = create_temp_file().expect("Failed to create temp file");
1315        env::set_var("GIT_PAIR_ROSTER_FILE", temp_path.to_str().unwrap());
1316
1317        // Remove the file to test truly empty state
1318        fs::remove_file(&temp_path).ok();
1319
1320        // Test empty global roster
1321        let roster = get_global_roster().expect("Should get empty global roster");
1322        assert!(roster.is_empty());
1323
1324        // Clean up
1325        env::remove_var("GIT_PAIR_ROSTER_FILE");
1326    }
1327
1328    #[test]
1329    fn test_remove_coauthor_by_name() {
1330        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1331        let test_dir = temp_dir.path();
1332        init_pair_config_in(test_dir).expect("Init should succeed");
1333
1334        // Add multiple co-authors
1335        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1336            .expect("Add should succeed");
1337        add_coauthor_in(test_dir, "Jane", "Smith", "jane.smith@example.com")
1338            .expect("Add should succeed");
1339
1340        // Remove by name
1341        let result = remove_coauthor_in(test_dir, "John Doe").expect("Remove should succeed");
1342        assert!(result.contains("Removed 1 co-author matching 'John Doe'"));
1343
1344        // Check remaining co-authors
1345        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1346        assert_eq!(coauthors.len(), 1);
1347        assert!(coauthors[0].contains("Jane Smith"));
1348        assert!(!coauthors[0].contains("John Doe"));
1349    }
1350
1351    #[test]
1352    fn test_remove_coauthor_by_email() {
1353        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1354        let test_dir = temp_dir.path();
1355        init_pair_config_in(test_dir).expect("Init should succeed");
1356
1357        // Add multiple co-authors
1358        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1359            .expect("Add should succeed");
1360        add_coauthor_in(test_dir, "Jane", "Smith", "jane.smith@example.com")
1361            .expect("Add should succeed");
1362
1363        // Remove by email
1364        let result =
1365            remove_coauthor_in(test_dir, "jane.smith@example.com").expect("Remove should succeed");
1366        assert!(result.contains("Removed 1 co-author matching 'jane.smith@example.com'"));
1367
1368        // Check remaining co-authors
1369        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1370        assert_eq!(coauthors.len(), 1);
1371        assert!(coauthors[0].contains("John Doe"));
1372        assert!(!coauthors[0].contains("Jane Smith"));
1373    }
1374
1375    #[test]
1376    fn test_remove_coauthor_by_global_alias() {
1377        let _lock = GLOBAL_ROSTER_TEST_LOCK.lock().unwrap();
1378
1379        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1380        let test_dir = temp_dir.path();
1381        let temp_path = create_temp_file().expect("Failed to create temp file");
1382        env::set_var("GIT_PAIR_ROSTER_FILE", temp_path.to_str().unwrap());
1383
1384        // Setup global roster
1385        add_global_coauthor("alice", "Alice Johnson", "alice@example.com")
1386            .expect("Should add to global roster");
1387        add_global_coauthor("bob", "Bob Wilson", "bob@example.com")
1388            .expect("Should add to global roster");
1389
1390        // Initialize and add co-authors
1391        init_pair_config_in(test_dir).expect("Init should succeed");
1392        add_coauthor_from_global_in(test_dir, "alice").expect("Add alice should succeed");
1393        add_coauthor_from_global_in(test_dir, "bob").expect("Add bob should succeed");
1394
1395        // Remove by alias
1396        let result = remove_coauthor_in(test_dir, "alice").expect("Remove should succeed");
1397        assert!(result.contains("Removed 1 co-author matching 'alice'"));
1398
1399        // Check remaining co-authors
1400        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1401        assert_eq!(coauthors.len(), 1);
1402        assert!(coauthors[0].contains("Bob Wilson"));
1403        assert!(!coauthors[0].contains("Alice Johnson"));
1404
1405        // Clean up
1406        env::remove_var("GIT_PAIR_ROSTER_FILE");
1407    }
1408
1409    #[test]
1410    fn test_remove_coauthor_not_found() {
1411        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1412        let test_dir = temp_dir.path();
1413        init_pair_config_in(test_dir).expect("Init should succeed");
1414
1415        // Add one co-author
1416        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1417            .expect("Add should succeed");
1418
1419        // Try to remove non-existent co-author
1420        let result = remove_coauthor_in(test_dir, "Jane Smith");
1421        assert!(result.is_err());
1422        assert!(result.unwrap_err().contains("not found on branch"));
1423
1424        // Verify original co-author is still there
1425        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1426        assert_eq!(coauthors.len(), 1);
1427        assert!(coauthors[0].contains("John Doe"));
1428    }
1429
1430    #[test]
1431    fn test_remove_coauthor_not_initialized() {
1432        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1433        let test_dir = temp_dir.path();
1434
1435        let result = remove_coauthor_in(test_dir, "John Doe");
1436        assert!(result.is_err());
1437        assert!(result.unwrap_err().contains("git-pair not initialized"));
1438    }
1439
1440    #[test]
1441    fn test_remove_last_coauthor_removes_hook() {
1442        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1443        let test_dir = temp_dir.path();
1444        init_pair_config_in(test_dir).expect("Init should succeed");
1445
1446        // Add one co-author
1447        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1448            .expect("Add should succeed");
1449
1450        // Verify hook exists
1451        assert!(test_dir.join(".git/hooks/prepare-commit-msg").exists());
1452
1453        // Remove the only co-author
1454        remove_coauthor_in(test_dir, "John Doe").expect("Remove should succeed");
1455
1456        // Verify hook was removed since no co-authors remain
1457        assert!(!test_dir.join(".git/hooks/prepare-commit-msg").exists());
1458
1459        // Verify no co-authors remain
1460        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1461        assert!(coauthors.is_empty());
1462    }
1463
1464    #[test]
1465    fn test_remove_coauthor_case_insensitive() {
1466        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1467        let test_dir = temp_dir.path();
1468        init_pair_config_in(test_dir).expect("Init should succeed");
1469
1470        // Add co-author
1471        add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1472            .expect("Add should succeed");
1473
1474        // Remove with different case
1475        let result = remove_coauthor_in(test_dir, "john doe").expect("Remove should succeed");
1476        assert!(result.contains("Removed 1 co-author matching 'john doe'"));
1477
1478        // Verify co-author was removed
1479        let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1480        assert!(coauthors.is_empty());
1481    }
1482
1483    // Tests for improved hook management
1484
1485    #[test]
1486    fn test_is_effectively_empty() {
1487        assert!(is_effectively_empty(""));
1488        assert!(is_effectively_empty("   \n  \t  "));
1489        assert!(is_effectively_empty("#!/bin/sh"));
1490        assert!(is_effectively_empty("#!/bin/sh\n# comment"));
1491        assert!(is_effectively_empty("#!/bin/sh\n\n# comment\n   "));
1492        assert!(!is_effectively_empty("#!/bin/sh\necho 'something'"));
1493        assert!(!is_effectively_empty("echo 'test'"));
1494    }
1495
1496    #[test]
1497    fn test_merge_git_pair_section_new_file() {
1498        let existing = "";
1499        let git_pair_section = "# BEGIN git-pair\necho 'git-pair'\n# END git-pair";
1500
1501        let result = merge_git_pair_section(existing, git_pair_section).unwrap();
1502        assert!(result.starts_with("#!/bin/sh\n"));
1503        assert!(result.contains("# BEGIN git-pair"));
1504        assert!(result.contains("# END git-pair"));
1505    }
1506
1507    #[test]
1508    fn test_merge_git_pair_section_existing_content() {
1509        let existing = "#!/bin/sh\necho 'existing hook'";
1510        let git_pair_section = "# BEGIN git-pair\necho 'git-pair'\n# END git-pair";
1511
1512        let result = merge_git_pair_section(existing, git_pair_section).unwrap();
1513        assert!(result.contains("echo 'existing hook'"));
1514        assert!(result.contains("# BEGIN git-pair"));
1515        assert!(result.ends_with("# END git-pair"));
1516    }
1517
1518    #[test]
1519    fn test_merge_git_pair_section_replace_existing() {
1520        let existing =
1521            "#!/bin/sh\necho 'before'\n# BEGIN git-pair\necho 'old'\n# END git-pair\necho 'after'";
1522        let git_pair_section = "# BEGIN git-pair\necho 'new'\n# END git-pair";
1523
1524        let result = merge_git_pair_section(existing, git_pair_section).unwrap();
1525        assert!(result.contains("echo 'before'"));
1526        assert!(result.contains("echo 'new'"));
1527        assert!(result.contains("echo 'after'"));
1528        assert!(!result.contains("echo 'old'"));
1529    }
1530
1531    #[test]
1532    fn test_remove_git_pair_section_success() {
1533        let content = "#!/bin/sh\necho 'before'\n# BEGIN git-pair\necho 'git-pair'\n# END git-pair\necho 'after'";
1534
1535        let result = remove_git_pair_section(content).unwrap();
1536        assert!(result.contains("echo 'before'"));
1537        assert!(result.contains("echo 'after'"));
1538        assert!(!result.contains("git-pair"));
1539        assert!(!result.contains("BEGIN"));
1540    }
1541
1542    #[test]
1543    fn test_remove_git_pair_section_not_found() {
1544        let content = "#!/bin/sh\necho 'no git-pair here'";
1545
1546        let result = remove_git_pair_section(content);
1547        assert!(result.is_none());
1548    }
1549
1550    #[test]
1551    fn test_remove_git_pair_section_only_content() {
1552        let content = "#!/bin/sh\n# BEGIN git-pair\necho 'git-pair'\n# END git-pair";
1553
1554        let result = remove_git_pair_section(content).unwrap();
1555        assert_eq!(result.trim(), "#!/bin/sh");
1556    }
1557
1558    #[test]
1559    fn test_hook_preservation_workflow() {
1560        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1561        let test_dir = temp_dir.path();
1562
1563        // Create an existing hook
1564        let hooks_dir = test_dir.join(".git/hooks");
1565        fs::create_dir_all(&hooks_dir).expect("Should create hooks dir");
1566        let hook_file = hooks_dir.join("prepare-commit-msg");
1567        let existing_hook = "#!/bin/sh\necho 'existing hook logic'";
1568        fs::write(&hook_file, existing_hook).expect("Should write existing hook");
1569
1570        // Initialize git-pair and add co-author
1571        init_pair_config_in(test_dir).expect("Init should succeed");
1572        add_coauthor_in(test_dir, "John", "Doe", "john@example.com").expect("Add should succeed");
1573
1574        // Check that existing hook content is preserved
1575        let hook_content = fs::read_to_string(&hook_file).expect("Hook should exist");
1576        assert!(hook_content.contains("existing hook logic"));
1577        assert!(hook_content.contains("# BEGIN git-pair"));
1578        assert!(hook_content.contains("# END git-pair"));
1579
1580        // Clear co-authors and check that only git-pair section is removed
1581        clear_coauthors_in(test_dir).expect("Clear should succeed");
1582
1583        let remaining_content = fs::read_to_string(&hook_file).expect("Hook should still exist");
1584        assert!(remaining_content.contains("existing hook logic"));
1585        assert!(!remaining_content.contains("git-pair"));
1586    }
1587
1588    #[test]
1589    fn test_hook_complete_removal() {
1590        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1591        let test_dir = temp_dir.path();
1592
1593        // Initialize git-pair and add co-author (no existing hook)
1594        init_pair_config_in(test_dir).expect("Init should succeed");
1595        add_coauthor_in(test_dir, "John", "Doe", "john@example.com").expect("Add should succeed");
1596
1597        let hook_file = test_dir.join(".git/hooks/prepare-commit-msg");
1598        assert!(hook_file.exists());
1599
1600        // Clear co-authors - should remove entire hook file since it only contains git-pair content
1601        clear_coauthors_in(test_dir).expect("Clear should succeed");
1602
1603        assert!(!hook_file.exists());
1604    }
1605
1606    #[test]
1607    fn test_hook_update_preserves_existing() {
1608        let temp_dir = setup_test_repo().expect("Failed to setup test repo");
1609        let test_dir = temp_dir.path();
1610
1611        // Create existing hook
1612        let hooks_dir = test_dir.join(".git/hooks");
1613        fs::create_dir_all(&hooks_dir).expect("Should create hooks dir");
1614        let hook_file = hooks_dir.join("prepare-commit-msg");
1615        fs::write(&hook_file, "#!/bin/sh\necho 'original'").expect("Should write hook");
1616
1617        // Initialize and add first co-author
1618        init_pair_config_in(test_dir).expect("Init should succeed");
1619        add_coauthor_in(test_dir, "John", "Doe", "john@example.com").expect("Add should succeed");
1620
1621        // Add second co-author (should update hook)
1622        add_coauthor_in(test_dir, "Jane", "Smith", "jane@example.com").expect("Add should succeed");
1623
1624        // Original content should still be there
1625        let hook_content = fs::read_to_string(&hook_file).expect("Hook should exist");
1626        assert!(hook_content.contains("echo 'original'"));
1627        assert!(hook_content.contains("git-pair"));
1628
1629        // Should only have one git-pair section
1630        assert_eq!(hook_content.matches("# BEGIN git-pair").count(), 1);
1631        assert_eq!(hook_content.matches("# END git-pair").count(), 1);
1632    }
1633}