git_x/
switch_recent.rs

1use crate::core::interactive::Interactive;
2use crate::{GitXError, Result};
3use console::style;
4use std::process::Command;
5
6pub fn run() -> Result<String> {
7    let branches = get_recent_branches()?;
8
9    if branches.is_empty() {
10        return Err(GitXError::GitCommand(
11            "No recent branches found".to_string(),
12        ));
13    }
14
15    // Check if we're in an interactive environment
16    if !Interactive::is_interactive() {
17        // In non-interactive environments (like tests), just switch to the most recent branch
18        let selected_branch = &branches[0];
19        switch_to_branch(selected_branch)?;
20        return Ok(format!(
21            "Switched to branch '{}'",
22            style(selected_branch).green().bold()
23        ));
24    }
25
26    let selected_branch =
27        Interactive::branch_picker(&branches, Some("Select a recent branch to switch to"))?;
28    switch_to_branch(&selected_branch)?;
29
30    Ok(format!(
31        "Switched to branch '{}'",
32        style(&selected_branch).green().bold()
33    ))
34}
35
36fn get_recent_branches() -> Result<Vec<String>> {
37    let output = Command::new("git")
38        .args([
39            "for-each-ref",
40            "--sort=-committerdate",
41            "--format=%(refname:short)",
42            "refs/heads/",
43        ])
44        .output()?;
45
46    if !output.status.success() {
47        return Err(GitXError::GitCommand(format!(
48            "Failed to get recent branches: {}",
49            String::from_utf8_lossy(&output.stderr)
50        )));
51    }
52
53    let current_branch = get_current_branch().unwrap_or_default();
54    let branches: Vec<String> = String::from_utf8_lossy(&output.stdout)
55        .lines()
56        .map(|s| s.trim().to_string())
57        .filter(|branch| !branch.is_empty() && branch != &current_branch)
58        .take(10) // Limit to 10 most recent branches
59        .collect();
60
61    Ok(branches)
62}
63
64fn get_current_branch() -> Result<String> {
65    let output = Command::new("git")
66        .args(["branch", "--show-current"])
67        .output()?;
68
69    if !output.status.success() {
70        return Err(GitXError::GitCommand(format!(
71            "Failed to get current branch: {}",
72            String::from_utf8_lossy(&output.stderr)
73        )));
74    }
75
76    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
77}
78
79fn switch_to_branch(branch: &str) -> Result<()> {
80    let status = Command::new("git").args(["checkout", branch]).status()?;
81
82    if !status.success() {
83        return Err(GitXError::GitCommand(format!(
84            "Failed to switch to branch '{branch}'"
85        )));
86    }
87
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::GitXError;
95
96    #[test]
97    fn test_get_recent_branches_success() {
98        match get_recent_branches() {
99            Ok(branches) => {
100                assert!(branches.len() <= 10, "Should limit to 10 branches");
101                for branch in branches {
102                    assert!(!branch.is_empty(), "Branch names should not be empty");
103                }
104            }
105            Err(GitXError::GitCommand(_)) => {
106                // Expected in non-git environments or when git command fails
107            }
108            Err(GitXError::Io(_)) => {
109                // Expected when git binary is not available
110            }
111            Err(_) => panic!("Unexpected error type"),
112        }
113    }
114
115    #[test]
116    fn test_get_current_branch_success() {
117        match get_current_branch() {
118            Ok(_branch) => {
119                // In a real git repo, branch name should not be empty
120                // In some cases (like detached HEAD), it might be empty, which is valid
121            }
122            Err(GitXError::GitCommand(_)) => {
123                // Expected in non-git environments
124            }
125            Err(GitXError::Io(_)) => {
126                // Expected when git binary is not available
127            }
128            Err(_) => panic!("Unexpected error type"),
129        }
130    }
131
132    #[test]
133    fn test_switch_to_branch_invalid_branch() {
134        let result = switch_to_branch("non-existent-branch-12345");
135        assert!(result.is_err(), "Should fail for non-existent branch");
136
137        if let Err(e) = result {
138            match e {
139                GitXError::GitCommand(msg) => {
140                    assert!(
141                        msg.contains("Failed to switch to branch"),
142                        "Error message should mention branch switching"
143                    );
144                }
145                GitXError::Io(_) => {
146                    // Expected when git binary is not available
147                }
148                _ => panic!("Unexpected error type"),
149            }
150        }
151    }
152
153    #[test]
154    fn test_run_no_git_repo() {
155        // Test the behavior when not in a git repository
156        // We test this by checking get_recent_branches() which is the first step of run()
157        let result = get_recent_branches();
158        match result {
159            Ok(branches) => {
160                // If we're actually in a git repo, just verify branches are valid
161                assert!(branches.len() <= 10, "Should limit to 10 branches");
162                for branch in branches {
163                    assert!(!branch.is_empty(), "Branch names should not be empty");
164                }
165                println!("In git repo - skipping non-git test");
166            }
167            Err(GitXError::GitCommand(_)) => {
168                // Expected behavior in non-git repo - git command fails
169                println!("Not in git repo - git command failed as expected");
170            }
171            Err(GitXError::Io(_)) => {
172                // Expected when git binary is not available
173                println!("Git binary not available - IO error as expected");
174            }
175            Err(_) => panic!("Unexpected error type"),
176        }
177    }
178
179    #[test]
180    fn test_show_branch_picker_with_branches() {
181        let branches = ["feature/test".to_string(), "main".to_string()];
182
183        // This test verifies the function exists and can be called
184        // We can't actually test the interactive picker in a unit test environment
185        // because it would hang waiting for user input
186
187        // Instead, let's just verify the function signature and that we have branches
188        assert!(!branches.is_empty(), "Should have branches to pick from");
189        assert_eq!(branches.len(), 2, "Should have exactly 2 branches");
190        assert_eq!(
191            branches[0], "feature/test",
192            "First branch should be feature/test"
193        );
194        assert_eq!(branches[1], "main", "Second branch should be main");
195
196        // Note: We deliberately don't call show_branch_picker here because it would
197        // hang in the test environment waiting for interactive input
198    }
199
200    #[test]
201    fn test_show_branch_picker_empty_branches() {
202        let branches: Vec<String> = vec![];
203
204        // Test that we handle empty branch list properly
205        assert!(branches.is_empty(), "Should have no branches");
206        assert_eq!(branches.len(), 0, "Should have exactly 0 branches");
207
208        // Note: We don't call show_branch_picker with empty branches because
209        // it would still try to create an interactive picker which could hang
210        // Instead we test the empty branch logic in the run() function
211    }
212
213    #[test]
214    fn test_switch_to_branch_valid_args() {
215        // Test that switch_to_branch properly formats git checkout command
216        // This will fail since we're not in the branch, but we can verify error handling
217        let result = switch_to_branch("main");
218        match result {
219            Ok(_) => {
220                // Might succeed if we're actually in a git repo with a main branch
221            }
222            Err(GitXError::GitCommand(msg)) => {
223                // Expected - either branch doesn't exist or checkout failed
224                assert!(
225                    msg.contains("Failed to switch to branch"),
226                    "Should mention switching failure"
227                );
228            }
229            Err(GitXError::Io(_)) => {
230                // Expected when git binary is not available
231            }
232            Err(_) => panic!("Unexpected error type"),
233        }
234    }
235
236    #[test]
237    fn test_gitx_error_types() {
238        // Test that our functions return the correct GitXError variants
239
240        // Test IO error conversion
241        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "git not found");
242        let gitx_error: GitXError = io_error.into();
243        match gitx_error {
244            GitXError::Io(_) => {} // Expected
245            _ => panic!("Should convert to Io error"),
246        }
247
248        // Test GitCommand error
249        let git_error = GitXError::GitCommand("test error".to_string());
250        assert_eq!(git_error.to_string(), "Git command failed: test error");
251    }
252
253    #[test]
254    fn test_run_function_complete_flow() {
255        // Test the complete run() function flow, but only up to the point where
256        // it would require interactive input
257
258        // Test that we can get branches (or get appropriate error)
259        let branches_result = get_recent_branches();
260        match branches_result {
261            Ok(branches) => {
262                // If we got branches, verify they're properly formatted
263                assert!(branches.len() <= 10, "Should limit to 10 branches");
264                for branch in &branches {
265                    assert!(!branch.is_empty(), "Branch names should not be empty");
266                }
267
268                // If we have branches, we can't test the full flow without hanging
269                // so we just verify the branches are valid
270                if branches.is_empty() {
271                    println!(
272                        "No recent branches found - this would trigger the empty branches error"
273                    );
274                } else {
275                    println!(
276                        "Found {} recent branches - skipping interactive test to avoid hanging",
277                        branches.len()
278                    );
279                }
280            }
281            Err(_) => {
282                // Expected in non-git environments
283                println!("Failed to get recent branches - this would trigger the git error");
284            }
285        }
286    }
287}