From 8d81ce6ef43f517605a4533a884dbc995232abea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Madejski?= Date: Thu, 11 Sep 2025 11:17:33 +0200 Subject: [PATCH 1/4] Add core remove co-author functionality - Add remove_coauthor() function that removes specific co-authors - Support removal by name, email, or global roster alias - Implement case-insensitive name matching - Add matches_coauthor() helper for flexible identifier matching - Automatically update git hook when co-authors are removed - Remove git hook entirely when last co-author is removed - Add comprehensive unit tests for all removal scenarios --- src/lib.rs | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 271788c..93e137b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -284,6 +284,115 @@ fn remove_git_hook() -> Result<(), String> { remove_git_hook_in(¤t_dir) } +pub fn remove_coauthor(identifier: &str) -> Result { + let config_file = get_branch_config_file()?; + let branch_name = get_current_branch()?; + + // Check if git-pair is initialized for this branch + if !config_file.exists() { + return Err(format!( + "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.", + branch_name + )); + } + + // Read existing config + let existing_content = fs::read_to_string(&config_file) + .map_err(|e| format!("Error reading config file: {}", e))?; + + // Get current co-authors + let mut coauthor_lines: Vec = existing_content + .lines() + .filter(|line| line.starts_with("Co-authored-by:")) + .map(|line| line.to_string()) + .collect(); + + // Store original count for comparison + let original_count = coauthor_lines.len(); + + // Try to match by different criteria + coauthor_lines.retain(|line| !matches_coauthor(line, identifier)); + + if coauthor_lines.len() == original_count { + // No co-author was removed, check if it might be a global alias + if let Ok(roster) = get_global_roster() { + if let Some((_, name, email)) = roster.iter().find(|(alias, _, _)| alias == identifier) + { + // Try to remove by the actual name/email from the global roster + let full_name_pattern = name; + let email_pattern = email; + + coauthor_lines.retain(|line| { + !line.contains(full_name_pattern) && !line.contains(email_pattern) + }); + + if coauthor_lines.len() == original_count { + return Err(format!( + "Co-author matching alias '{}' ({} <{}>) not found on branch '{}'", + identifier, name, email, branch_name + )); + } + } else { + return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name)); + } + } else { + return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name)); + } + } + + // Reconstruct the config file content + let mut new_content = String::new(); + + // Add header + new_content.push_str(&format!( + "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n", + branch_name + )); + + // Add remaining co-authors + for coauthor in &coauthor_lines { + new_content.push_str(coauthor); + new_content.push('\n'); + } + + // Write back the updated content + fs::write(&config_file, new_content) + .map_err(|e| format!("Error writing to config file: {}", e))?; + + // Update the commit template + update_commit_template()?; + + let removed_count = original_count - coauthor_lines.len(); + if removed_count == 1 { + Ok(format!( + "Removed 1 co-author matching '{}' from branch '{}'", + identifier, branch_name + )) + } else { + Ok(format!( + "Removed {} co-authors matching '{}' from branch '{}'", + removed_count, identifier, branch_name + )) + } +} + +fn matches_coauthor(coauthor_line: &str, identifier: &str) -> bool { + // Match by full name (case-insensitive) + if coauthor_line + .to_lowercase() + .contains(&identifier.to_lowercase()) + { + return true; + } + + // Match by email + if coauthor_line.contains(identifier) { + return true; + } + + false +} + pub fn clear_coauthors() -> Result { let config_file = get_branch_config_file()?; let branch_name = get_current_branch()?; @@ -522,6 +631,9 @@ mod tests { use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; + // Import the helper function for tests + use super::matches_coauthor; + // Mutex to ensure global roster tests don't interfere with each other static GLOBAL_ROSTER_TEST_LOCK: Mutex<()> = Mutex::new(()); @@ -715,6 +827,103 @@ mod tests { Ok(coauthors) } + fn remove_coauthor_in(working_dir: &Path, identifier: &str) -> Result { + let config_file = get_branch_config_file_in(working_dir)?; + let branch_name = get_current_branch_in(working_dir)?; + + // Check if git-pair is initialized for this branch + if !config_file.exists() { + return Err(format!( + "git-pair not initialized for branch '{}'. Please run 'git-pair init' first.", + branch_name + )); + } + + // Read existing config + let existing_content = fs::read_to_string(&config_file) + .map_err(|e| format!("Error reading config file: {}", e))?; + + // Get current co-authors + let mut coauthor_lines: Vec = existing_content + .lines() + .filter(|line| line.starts_with("Co-authored-by:")) + .map(|line| line.to_string()) + .collect(); + + // Store original count for comparison + let original_count = coauthor_lines.len(); + + // Try to match by different criteria + coauthor_lines.retain(|line| !matches_coauthor(line, identifier)); + + if coauthor_lines.len() == original_count { + // No co-author was removed, check if it might be a global alias + if let Ok(roster) = get_global_roster() { + if let Some((_, name, email)) = + roster.iter().find(|(alias, _, _)| alias == identifier) + { + // Try to remove by the actual name/email from the global roster + let full_name_pattern = name; + let email_pattern = email; + + coauthor_lines.retain(|line| { + !line.contains(full_name_pattern) && !line.contains(email_pattern) + }); + + if coauthor_lines.len() == original_count { + return Err(format!( + "Co-author matching alias '{}' ({} <{}>) not found on branch '{}'", + identifier, name, email, branch_name + )); + } + } else { + return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name)); + } + } else { + return Err(format!("Co-author '{}' not found on branch '{}'. Use 'git-pair status' to see current co-authors.", identifier, branch_name)); + } + } + + // Reconstruct the config file content + let mut new_content = String::new(); + + // Add header + new_content.push_str(&format!( + "# git-pair configuration file for branch '{}'\n# Co-authors will be listed here\n", + branch_name + )); + + // Add remaining co-authors + for coauthor in &coauthor_lines { + new_content.push_str(coauthor); + new_content.push('\n'); + } + + // Write back the updated content + fs::write(&config_file, new_content) + .map_err(|e| format!("Error writing to config file: {}", e))?; + + // Update git hook + if coauthor_lines.is_empty() { + remove_git_hook_in(working_dir)?; + } else { + install_git_hook_in(working_dir)?; + } + + let removed_count = original_count - coauthor_lines.len(); + if removed_count == 1 { + Ok(format!( + "Removed 1 co-author matching '{}' from branch '{}'", + identifier, branch_name + )) + } else { + Ok(format!( + "Removed {} co-authors matching '{}' from branch '{}'", + removed_count, identifier, branch_name + )) + } + } + fn clear_coauthors_in(working_dir: &Path) -> Result { let config_file = get_branch_config_file_in(working_dir)?; let branch_name = get_current_branch_in(working_dir)?; @@ -1116,6 +1325,161 @@ mod tests { env::remove_var("GIT_PAIR_ROSTER_FILE"); } + #[test] + fn test_remove_coauthor_by_name() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + init_pair_config_in(test_dir).expect("Init should succeed"); + + // Add multiple co-authors + add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com") + .expect("Add should succeed"); + add_coauthor_in(test_dir, "Jane", "Smith", "jane.smith@example.com") + .expect("Add should succeed"); + + // Remove by name + let result = remove_coauthor_in(test_dir, "John Doe").expect("Remove should succeed"); + assert!(result.contains("Removed 1 co-author matching 'John Doe'")); + + // Check remaining co-authors + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert_eq!(coauthors.len(), 1); + assert!(coauthors[0].contains("Jane Smith")); + assert!(!coauthors[0].contains("John Doe")); + } + + #[test] + fn test_remove_coauthor_by_email() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + init_pair_config_in(test_dir).expect("Init should succeed"); + + // Add multiple co-authors + add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com") + .expect("Add should succeed"); + add_coauthor_in(test_dir, "Jane", "Smith", "jane.smith@example.com") + .expect("Add should succeed"); + + // Remove by email + let result = + remove_coauthor_in(test_dir, "jane.smith@example.com").expect("Remove should succeed"); + assert!(result.contains("Removed 1 co-author matching 'jane.smith@example.com'")); + + // Check remaining co-authors + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert_eq!(coauthors.len(), 1); + assert!(coauthors[0].contains("John Doe")); + assert!(!coauthors[0].contains("Jane Smith")); + } + + #[test] + fn test_remove_coauthor_by_global_alias() { + let _lock = GLOBAL_ROSTER_TEST_LOCK.lock().unwrap(); + + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + let temp_path = create_temp_file().expect("Failed to create temp file"); + env::set_var("GIT_PAIR_ROSTER_FILE", temp_path.to_str().unwrap()); + + // Setup global roster + add_global_coauthor("alice", "Alice Johnson", "alice@example.com") + .expect("Should add to global roster"); + add_global_coauthor("bob", "Bob Wilson", "bob@example.com") + .expect("Should add to global roster"); + + // Initialize and add co-authors + init_pair_config_in(test_dir).expect("Init should succeed"); + add_coauthor_from_global_in(test_dir, "alice").expect("Add alice should succeed"); + add_coauthor_from_global_in(test_dir, "bob").expect("Add bob should succeed"); + + // Remove by alias + let result = remove_coauthor_in(test_dir, "alice").expect("Remove should succeed"); + assert!(result.contains("Removed 1 co-author matching 'alice'")); + + // Check remaining co-authors + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert_eq!(coauthors.len(), 1); + assert!(coauthors[0].contains("Bob Wilson")); + assert!(!coauthors[0].contains("Alice Johnson")); + + // Clean up + env::remove_var("GIT_PAIR_ROSTER_FILE"); + } + + #[test] + fn test_remove_coauthor_not_found() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + init_pair_config_in(test_dir).expect("Init should succeed"); + + // Add one co-author + add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com") + .expect("Add should succeed"); + + // Try to remove non-existent co-author + let result = remove_coauthor_in(test_dir, "Jane Smith"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found on branch")); + + // Verify original co-author is still there + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert_eq!(coauthors.len(), 1); + assert!(coauthors[0].contains("John Doe")); + } + + #[test] + fn test_remove_coauthor_not_initialized() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + + let result = remove_coauthor_in(test_dir, "John Doe"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("git-pair not initialized")); + } + + #[test] + fn test_remove_last_coauthor_removes_hook() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + init_pair_config_in(test_dir).expect("Init should succeed"); + + // Add one co-author + add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com") + .expect("Add should succeed"); + + // Verify hook exists + assert!(test_dir.join(".git/hooks/prepare-commit-msg").exists()); + + // Remove the only co-author + remove_coauthor_in(test_dir, "John Doe").expect("Remove should succeed"); + + // Verify hook was removed since no co-authors remain + assert!(!test_dir.join(".git/hooks/prepare-commit-msg").exists()); + + // Verify no co-authors remain + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert!(coauthors.is_empty()); + } + + #[test] + fn test_remove_coauthor_case_insensitive() { + let temp_dir = setup_test_repo().expect("Failed to setup test repo"); + let test_dir = temp_dir.path(); + init_pair_config_in(test_dir).expect("Init should succeed"); + + // Add co-author + add_coauthor_in(test_dir, "John", "Doe", "john.doe@example.com") + .expect("Add should succeed"); + + // Remove with different case + let result = remove_coauthor_in(test_dir, "john doe").expect("Remove should succeed"); + assert!(result.contains("Removed 1 co-author matching 'john doe'")); + + // Verify co-author was removed + let coauthors = get_coauthors_in(test_dir).expect("Get coauthors should succeed"); + assert!(coauthors.is_empty()); + } + // Tests for improved hook management #[test] From d178da5644f8519a12fb5ebe361d39eeb8a23ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Madejski?= Date: Thu, 11 Sep 2025 11:17:52 +0200 Subject: [PATCH 2/4] Add CLI interface for remove command - Add 'remove' command to CLI with proper argument parsing - Support usage: git-pair remove - Update help text with remove command documentation - Add usage examples for all removal methods - Include helpful error messages and usage hints --- src/main.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 402312e..81a6165 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use git_pair::{ add_coauthor, add_coauthor_from_global, add_global_coauthor, clear_coauthors, get_coauthors, - get_global_roster, init_pair_config, + get_global_roster, init_pair_config, remove_coauthor, }; use std::env; @@ -59,6 +59,21 @@ fn main() { Ok(message) => println!("{}", message), Err(e) => eprintln!("Error: {}", e), }, + "remove" => { + if args.len() >= 3 { + let identifier = &args[2]; + match remove_coauthor(identifier) { + Ok(message) => println!("{}", message), + Err(e) => eprintln!("Error: {}", e), + } + } else { + eprintln!("Usage: git-pair remove "); + eprintln!("Examples:"); + eprintln!(" git-pair remove \"John Doe\""); + eprintln!(" git-pair remove john.doe@example.com"); + eprintln!(" git-pair remove alice"); + } + } "status" | "list" => { if args.len() >= 3 && args[2] == "--global" { // List global roster @@ -116,6 +131,7 @@ COMMANDS: add Add a co-author to current branch add Add co-author from global roster add --global Add co-author to global roster + remove Remove a specific co-author from current branch clear Remove all co-authors from current branch status Show current branch co-authors list --global Show global roster @@ -130,6 +146,9 @@ EXAMPLES: git-pair add John Doe john.doe@company.com git-pair add --global alice "Alice Johnson" alice@company.com git-pair add alice + git-pair remove "John Doe" + git-pair remove john.doe@company.com + git-pair remove alice git-pair status git-pair list --global "#, From eb2249fb51ad72d97ffabd3a993b91ac81cb8e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Madejski?= Date: Thu, 11 Sep 2025 11:18:09 +0200 Subject: [PATCH 3/4] Add integration tests for remove functionality - Add 5 new integration tests covering remove operations: * Remove by name (Test 15) * Remove by email (Test 16) * Remove by global alias (Test 17) * Error handling for non-existent co-authors (Test 18) * Hook removal when last co-author removed (Test 19) - Update test count from 14 to 19 tests - Verify end-to-end functionality with real git operations --- integration_test.sh | 56 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/integration_test.sh b/integration_test.sh index c0ce214..6a25d05 100755 --- a/integration_test.sh +++ b/integration_test.sh @@ -156,6 +156,58 @@ if [[ ! "$ERROR_OUTPUT" == *"not found in global roster"* ]]; then exit 1 fi +echo "✅ Test 15: Remove co-authors - by name" +./git-pair remove "Alice Johnson" +REMOVE_STATUS=$(./git-pair status) +if [[ "$REMOVE_STATUS" == *"Alice Johnson"* ]] || [[ ! "$REMOVE_STATUS" == *"Bob Wilson"* ]]; then + echo "❌ Remove by name failed" + echo "Status after remove: $REMOVE_STATUS" + exit 1 +fi + +echo "✅ Test 16: Remove co-authors - by email" +# Add Alice back and remove by email +./git-pair add alice +./git-pair remove "bob@company.com" +REMOVE_STATUS2=$(./git-pair status) +if [[ "$REMOVE_STATUS2" == *"Bob Wilson"* ]] || [[ ! "$REMOVE_STATUS2" == *"Alice Johnson"* ]]; then + echo "❌ Remove by email failed" + echo "Status after remove: $REMOVE_STATUS2" + exit 1 +fi + +echo "✅ Test 17: Remove co-authors - by alias" +# Add Bob back and remove by alias +./git-pair add bob +./git-pair remove alice +REMOVE_STATUS3=$(./git-pair status) +if [[ "$REMOVE_STATUS3" == *"Alice Johnson"* ]] || [[ ! "$REMOVE_STATUS3" == *"Bob Wilson"* ]]; then + echo "❌ Remove by alias failed" + echo "Status after remove: $REMOVE_STATUS3" + exit 1 +fi + +echo "✅ Test 18: Remove non-existent co-author" +ERROR_OUTPUT2=$(./git-pair remove "Charlie Brown" 2>&1 || true) +if [[ ! "$ERROR_OUTPUT2" == *"not found on branch"* ]]; then + echo "❌ Non-existent co-author remove error handling failed" + echo "Error output: $ERROR_OUTPUT2" + exit 1 +fi + +echo "✅ Test 19: Remove last co-author removes hook" +./git-pair remove bob +if [ -f ".git/hooks/prepare-commit-msg" ]; then + echo "❌ Git hook not removed after removing last co-author" + exit 1 +fi +FINAL_STATUS=$(./git-pair status) +if [[ ! "$FINAL_STATUS" == *"No co-authors configured"* ]]; then + echo "❌ Status should show no co-authors after removing all" + echo "Final status: $FINAL_STATUS" + exit 1 +fi + # Cleanup temporary HOME rm -rf "$TEMP_ROSTER_FILE" unset GIT_PAIR_ROSTER_FILE @@ -164,5 +216,5 @@ unset GIT_PAIR_ROSTER_FILE cd "$OLDPWD" rm -rf "$TEST_DIR" -echo "🎉 All 14 integration tests passed!" -echo "🚀 git-pair with global roster is ready for use!" +echo "🎉 All 19 integration tests passed!" +echo "🚀 git-pair with remove functionality is ready for use!" From 885208479c1c20a2e910fc8be815977d4e03988c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Madejski?= Date: Thu, 11 Sep 2025 11:24:31 +0200 Subject: [PATCH 4/4] docs: update README with remove co-author functionality - Add remove command to features list with granular management - Document remove command usage with examples for name, email, and alias - Add remove command to commands reference table - Include practical examples for mob programming and dynamic teams - Show flexible co-author management during development sessions --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 79fe45b..a069a86 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A Git extension for managing pair programming sessions. Easily configure Git to - 👥 **Multiple authors**: Add multiple co-authors to your commits - 🔄 **Automatic switching**: Co-authors change automatically when you switch branches - ⚡ **Global roster**: Save frequently used co-authors with aliases for quick access across all repositories -- 🧹 **Clean state management**: Clear pair configuration per branch when switching between solo and pair work +- 🧹 **Flexible co-author management**: Remove individual co-authors or clear all at once - 📝 **Proper attribution**: Follows Git's standard Co-authored-by trailer format - ⚡ **Fast and lightweight**: Written in Rust for optimal performance - 🎯 **Branch isolation**: Complete separation of co-author configuration between branches @@ -93,6 +93,21 @@ git pair add john # Adds John Smith from global roster Adds co-authors to the current branch's pair programming session. Co-authors are branch-specific, so switching branches will use different co-author configurations. +### Remove Specific Co-authors + +```bash +# Remove by name +git pair remove "Jane Doe" + +# Remove by email +git pair remove jane.doe@company.com + +# Remove by global alias +git pair remove jane +``` + +Removes specific co-authors from the current branch while keeping others. Supports flexible removal by name, email address, or global roster alias. The Git hook is automatically updated to reflect the changes, or removed entirely if no co-authors remain. + ### Clear Pair Configuration ```bash @@ -146,7 +161,7 @@ Co-authored-by: John Smith `git-pair` stores its configuration in branch-specific files within `.git/git-pair/` directory. This means: - **Per-branch configuration**: Each branch has its own co-authors -- **Branch isolation**: Switching branches automatically uses the correct co-authors +- **Branch isolation**: Switching branches automatically uses the correct co-authors - **No global state pollution**: Configuration is repository-local - **Easy branch management**: Different teams can work on different branches with their own pair configurations - **Automatic cleanup**: Deleting a branch doesn't affect other branches' configurations @@ -155,7 +170,7 @@ Example configuration structure: ``` .git/git-pair/ ├── config-main # Co-authors for main branch -├── config-feature_auth # Co-authors for feature/auth branch +├── config-feature_auth # Co-authors for feature/auth branch └── config-bugfix_login # Co-authors for bugfix/login branch ~/.config/git-pair/ @@ -175,7 +190,7 @@ The per-branch co-author system enables powerful workflows: ### Use Cases - **Large Teams**: Different squads working on different features -- **Open Source**: Maintainers on main, contributors on feature branches +- **Open Source**: Maintainers on main, contributors on feature branches - **Client Work**: Different client teams on different feature branches - **Skill-based Pairing**: Frontend devs on UI branches, backend devs on API branches @@ -187,6 +202,7 @@ The per-branch co-author system enables powerful workflows: | `git pair add ` | Add a co-author to the current branch | | `git pair add ` | Add co-author from global roster using alias | | `git pair add --global ` | Add a co-author to global roster | +| `git pair remove ` | Remove a specific co-author from current branch | | `git pair clear` | Remove all co-authors from current branch | | `git pair status` | Show current branch's pair configuration | | `git pair list --global` | Show global roster of saved co-authors | @@ -252,7 +268,7 @@ git checkout main git pair init git pair add alice bob # Quick add from global roster -# Switch to feature branch - set up feature team +# Switch to feature branch - set up feature team git checkout -b feature/authentication git pair init git pair add sarah # Add Sarah from global roster @@ -262,7 +278,7 @@ git pair add "Carol Davis" carol@company.com # Add directly git commit -m "Implement login system" # Switch back to main - automatically uses Alice and Bob -git checkout main +git checkout main git commit -m "Update documentation" # Each branch maintains its own co-author configuration! @@ -277,6 +293,29 @@ git pair add "Eve Foster" eve@company.com # Check current branch's status git pair status + +# Remove specific team members as they leave the session +git pair remove dave # Remove by alias +git pair remove "Eve Foster" # Remove by name + +# Continue working with remaining co-authors +git commit -m "Implement feature together" +``` + +### Managing Co-authors During Development + +```bash +# Start with initial team +git pair add alice bob + +# Add someone who joins later +git pair add carol + +# Remove someone who leaves early +git pair remove alice + +# Continue with bob and carol +git commit -m "Complete feature implementation" ``` ### Switching Back to Solo Work