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 let safe_branch_name = branch_name.replace(['/', '\\', ':'], "_");
46
47 Ok(git_pair_dir.join(format!("config-{}", safe_branch_name)))
48}
49
50fn 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 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 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 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 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 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 if let Some((_, name, email)) = roster.iter().find(|(a, _, _)| a == alias) {
139 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 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 fs::create_dir_all(&git_pair_dir)
162 .map_err(|e| format!("Error creating git-pair directory: {}", e))?;
163
164 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 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 let existing_content = fs::read_to_string(&config_file)
201 .map_err(|e| format!("Error reading config file: {}", e))?;
202
203 let full_name = format!("{} {}", name, surname);
205 let coauthor_line = format!("Co-authored-by: {} <{}>\n", full_name, email);
206
207 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 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 let config_content = fs::read_to_string(&config_file)
233 .map_err(|e| format!("Error reading config file: {}", e))?;
234
235 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 remove_git_hook()?;
244 } else {
245 let current_dir =
247 env::current_dir().map_err(|e| format!("Error getting current directory: {}", e))?;
248 install_git_hook_in(¤t_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 if let Some(new_content) = remove_git_pair_section(&hook_content) {
266 if is_effectively_empty(&new_content) {
267 fs::remove_file(&hook_file)
269 .map_err(|e| format!("Error removing git hook: {}", e))?;
270 } else {
271 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(¤t_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 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 let existing_content = fs::read_to_string(&config_file)
301 .map_err(|e| format!("Error reading config file: {}", e))?;
302
303 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 let original_count = coauthor_lines.len();
312
313 coauthor_lines.retain(|line| !matches_coauthor(line, identifier));
315
316 if coauthor_lines.len() == original_count {
317 if let Ok(roster) = get_global_roster() {
319 if let Some((_, name, email)) = roster.iter().find(|(alias, _, _)| alias == identifier)
320 {
321 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 let mut new_content = String::new();
345
346 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 for coauthor in &coauthor_lines {
354 new_content.push_str(coauthor);
355 new_content.push('\n');
356 }
357
358 fs::write(&config_file, new_content)
360 .map_err(|e| format!("Error writing to config file: {}", e))?;
361
362 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 if coauthor_line
382 .to_lowercase()
383 .contains(&identifier.to_lowercase())
384 {
385 return true;
386 }
387
388 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 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 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()?;
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
448fn 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
461fn 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 if let Some(begin_pos) = existing_content.find(BEGIN_MARKER) {
471 if let Some(end_pos) = existing_content.find(END_MARKER) {
472 let before = &existing_content[..begin_pos];
474 let after = &existing_content[end_pos + END_MARKER.len()..];
475
476 let before_trimmed = before.trim_end();
478 let after_trimmed = after.trim_start();
479
480 let mut result = String::new();
481
482 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 let mut result = String::new();
506
507 if existing_content.trim().is_empty() {
508 result.push_str("#!/bin/sh\n");
510 result.push_str(git_pair_section);
511 } else {
512 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
522fn 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 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 None
553 }
554 } else {
555 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 fs::create_dir_all(&hooks_dir).map_err(|e| format!("Error creating hooks directory: {}", e))?;
566
567 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 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 let new_content = merge_git_pair_section(&existing_content, git_pair_section)?;
605
606 fs::write(&hook_file, new_content).map_err(|e| format!("Error writing git hook: {}", e))?;
608
609 #[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 use super::matches_coauthor;
636
637 static GLOBAL_ROSTER_TEST_LOCK: Mutex<()> = Mutex::new(());
639
640 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 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 fs::write(&temp_path, "")?;
690 Ok(temp_path)
691 }
692
693 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 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 fs::create_dir_all(&git_pair_dir)
739 .map_err(|e| format!("Error creating git-pair directory: {}", e))?;
740
741 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 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 let existing_content = fs::read_to_string(&config_file)
779 .map_err(|e| format!("Error reading config file: {}", e))?;
780
781 let full_name = format!("{} {}", name, surname);
783 let coauthor_line = format!("Co-authored-by: {} <{}>\n", full_name, email);
784
785 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 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_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 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 let existing_content = fs::read_to_string(&config_file)
844 .map_err(|e| format!("Error reading config file: {}", e))?;
845
846 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 let original_count = coauthor_lines.len();
855
856 coauthor_lines.retain(|line| !matches_coauthor(line, identifier));
858
859 if coauthor_lines.len() == original_count {
860 if let Ok(roster) = get_global_roster() {
862 if let Some((_, name, email)) =
863 roster.iter().find(|(alias, _, _)| alias == identifier)
864 {
865 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 let mut new_content = String::new();
889
890 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 for coauthor in &coauthor_lines {
898 new_content.push_str(coauthor);
899 new_content.push('\n');
900 }
901
902 fs::write(&config_file, new_content)
904 .map_err(|e| format!("Error writing to config file: {}", e))?;
905
906 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 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 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_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 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 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 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_coauthor_in(working_dir, &first_name, &last_name, email)
986 }
987
988 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 Command::new("git")
996 .args(["init"])
997 .current_dir(repo_path)
998 .output()?;
999
1000 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 assert!(temp_dir.path().join(".git/git-pair").exists());
1023
1024 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 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 init_pair_config_in(temp_dir.path()).expect("First init should succeed");
1040
1041 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 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 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_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1084 .expect("First add should succeed");
1085
1086 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_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 let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1133 assert!(coauthors.is_empty());
1134
1135 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 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 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 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 assert!(hook_content.contains("CONFIG_FILE"));
1226 assert!(hook_content.contains("grep '^Co-authored-by:'"));
1227
1228 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_coauthors_in(test_dir).expect("Clear should succeed");
1236
1237 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 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 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 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 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 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 init_pair_config_in(test_dir).expect("Init should succeed");
1286
1287 add_global_coauthor("bob", "Bob Wilson", "bob@example.com")
1289 .expect("Should add to global roster");
1290
1291 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 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 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 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 fs::remove_file(&temp_path).ok();
1319
1320 let roster = get_global_roster().expect("Should get empty global roster");
1322 assert!(roster.is_empty());
1323
1324 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_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 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 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_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 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 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 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 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 let result = remove_coauthor_in(test_dir, "alice").expect("Remove should succeed");
1397 assert!(result.contains("Removed 1 co-author matching 'alice'"));
1398
1399 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 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_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1417 .expect("Add should succeed");
1418
1419 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 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_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1448 .expect("Add should succeed");
1449
1450 assert!(test_dir.join(".git/hooks/prepare-commit-msg").exists());
1452
1453 remove_coauthor_in(test_dir, "John Doe").expect("Remove should succeed");
1455
1456 assert!(!test_dir.join(".git/hooks/prepare-commit-msg").exists());
1458
1459 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_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com")
1472 .expect("Add should succeed");
1473
1474 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 let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed");
1480 assert!(coauthors.is_empty());
1481 }
1482
1483 #[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 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 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 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_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 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_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 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 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_coauthor_in(test_dir, "Jane", "Smith", "jane@example.com").expect("Add should succeed");
1623
1624 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 assert_eq!(hook_content.matches("# BEGIN git-pair").count(), 1);
1631 assert_eq!(hook_content.matches("# END git-pair").count(), 1);
1632 }
1633}