cascade_cli/git/
branch_manager.rs

1use crate::errors::Result;
2use crate::git::GitRepository;
3use serde::{Deserialize, Serialize};
4
5/// Information about upstream tracking
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct UpstreamInfo {
8    pub remote: String,
9    pub branch: String,
10    pub full_name: String, // e.g., "origin/feature-auth"
11    pub ahead: usize,      // commits ahead of upstream
12    pub behind: usize,     // commits behind upstream
13}
14
15/// Information about a branch
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct BranchInfo {
18    pub name: String,
19    pub commit_hash: String,
20    pub is_current: bool,
21    pub upstream: Option<UpstreamInfo>,
22}
23
24/// Manages branch operations and metadata
25pub struct BranchManager {
26    git_repo: GitRepository,
27}
28
29impl BranchManager {
30    /// Create a new BranchManager
31    pub fn new(git_repo: GitRepository) -> Self {
32        Self { git_repo }
33    }
34
35    /// Get information about all branches
36    pub fn get_branch_info(&self) -> Result<Vec<BranchInfo>> {
37        let branches = self.git_repo.list_branches()?;
38        let current_branch = self.git_repo.get_current_branch().ok();
39
40        let mut branch_info = Vec::new();
41        for branch_name in branches {
42            let commit_hash = self.get_branch_commit_hash(&branch_name)?;
43            let is_current = current_branch.as_ref() == Some(&branch_name);
44            let upstream = self.get_upstream_info(&branch_name)?;
45
46            branch_info.push(BranchInfo {
47                name: branch_name,
48                commit_hash,
49                is_current,
50                upstream,
51            });
52        }
53
54        Ok(branch_info)
55    }
56
57    /// Get the commit hash for a specific branch safely without switching branches
58    fn get_branch_commit_hash(&self, branch_name: &str) -> Result<String> {
59        self.git_repo.get_branch_commit_hash(branch_name)
60    }
61
62    /// Get upstream tracking information for a branch
63    fn get_upstream_info(&self, branch_name: &str) -> Result<Option<UpstreamInfo>> {
64        // First, try to get the upstream tracking from git config
65        if let Some(upstream) = self.git_repo.get_upstream_branch(branch_name)? {
66            let (remote, remote_branch) = self.parse_upstream_name(&upstream)?;
67
68            // Calculate ahead/behind counts
69            let (ahead, behind) = self.calculate_ahead_behind_counts(branch_name, &upstream)?;
70
71            Ok(Some(UpstreamInfo {
72                remote,
73                branch: remote_branch,
74                full_name: upstream,
75                ahead,
76                behind,
77            }))
78        } else {
79            // No upstream configured
80            Ok(None)
81        }
82    }
83
84    /// Parse upstream name like "origin/feature-auth" into remote and branch
85    fn parse_upstream_name(&self, upstream: &str) -> Result<(String, String)> {
86        let parts: Vec<&str> = upstream.splitn(2, '/').collect();
87        if parts.len() == 2 {
88            Ok((parts[0].to_string(), parts[1].to_string()))
89        } else {
90            // Fallback - assume origin if no slash
91            Ok(("origin".to_string(), upstream.to_string()))
92        }
93    }
94
95    /// Calculate how many commits ahead/behind the local branch is from upstream
96    fn calculate_ahead_behind_counts(
97        &self,
98        local_branch: &str,
99        upstream_branch: &str,
100    ) -> Result<(usize, usize)> {
101        match self
102            .git_repo
103            .get_ahead_behind_counts(local_branch, upstream_branch)
104        {
105            Ok((ahead, behind)) => Ok((ahead, behind)),
106            Err(_) => {
107                // If we can't calculate (e.g., remote doesn't exist), return 0,0
108                Ok((0, 0))
109            }
110        }
111    }
112
113    /// Generate a safe branch name from a commit message
114    pub fn generate_branch_name(&self, message: &str) -> String {
115        let base_name = message
116            .to_lowercase()
117            .chars()
118            .map(|c| match c {
119                'a'..='z' | '0'..='9' => c,
120                _ => '-',
121            })
122            .collect::<String>()
123            .split('-')
124            .filter(|s| !s.is_empty())
125            .take(5) // Limit to first 5 words
126            .collect::<Vec<_>>()
127            .join("-");
128
129        // Ensure the branch name is unique
130        let mut counter = 1;
131        let mut candidate = base_name.clone();
132
133        while self.git_repo.branch_exists(&candidate) {
134            candidate = format!("{base_name}-{counter}");
135            counter += 1;
136        }
137
138        // Ensure it starts with a letter
139        if candidate.chars().next().is_none_or(|c| !c.is_alphabetic()) {
140            candidate = format!("feature-{candidate}");
141        }
142
143        candidate
144    }
145
146    /// Create a new branch with a generated name
147    pub fn create_branch_from_message(
148        &self,
149        message: &str,
150        target: Option<&str>,
151    ) -> Result<String> {
152        let branch_name = self.generate_branch_name(message);
153        self.git_repo.create_branch(&branch_name, target)?;
154        Ok(branch_name)
155    }
156
157    /// Set upstream tracking for a branch
158    pub fn set_upstream(&self, branch_name: &str, remote: &str, remote_branch: &str) -> Result<()> {
159        self.git_repo
160            .set_upstream(branch_name, remote, remote_branch)
161    }
162
163    /// Get upstream info for a specific branch
164    pub fn get_branch_upstream(&self, branch_name: &str) -> Result<Option<UpstreamInfo>> {
165        self.get_upstream_info(branch_name)
166    }
167
168    /// Check if a branch has upstream tracking
169    pub fn has_upstream(&self, branch_name: &str) -> Result<bool> {
170        Ok(self.get_upstream_info(branch_name)?.is_some())
171    }
172
173    /// Get the underlying Git repository
174    pub fn git_repo(&self) -> &GitRepository {
175        &self.git_repo
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::git::repository::*;
183    use git2::{Repository, Signature};
184    use tempfile::TempDir;
185
186    fn create_test_branch_manager() -> (TempDir, BranchManager) {
187        let temp_dir = TempDir::new().unwrap();
188        let repo_path = temp_dir.path();
189
190        // Initialize repository
191        let repo = Repository::init(repo_path).unwrap();
192
193        // Create initial commit
194        let signature = Signature::now("Test User", "test@example.com").unwrap();
195        let tree_id = {
196            let mut index = repo.index().unwrap();
197            index.write_tree().unwrap()
198        };
199        let tree = repo.find_tree(tree_id).unwrap();
200
201        repo.commit(
202            Some("HEAD"),
203            &signature,
204            &signature,
205            "Initial commit",
206            &tree,
207            &[],
208        )
209        .unwrap();
210
211        let git_repo = GitRepository::open(repo_path).unwrap();
212        let branch_manager = BranchManager::new(git_repo);
213
214        (temp_dir, branch_manager)
215    }
216
217    #[test]
218    fn test_branch_name_generation() {
219        let (_temp_dir, branch_manager) = create_test_branch_manager();
220
221        assert_eq!(
222            branch_manager.generate_branch_name("Add user authentication"),
223            "add-user-authentication"
224        );
225
226        assert_eq!(
227            branch_manager.generate_branch_name("Fix bug in payment system!!!"),
228            "fix-bug-in-payment-system"
229        );
230
231        assert_eq!(
232            branch_manager.generate_branch_name("123 numeric start"),
233            "feature-123-numeric-start"
234        );
235    }
236
237    #[test]
238    fn test_branch_creation() {
239        let (_temp_dir, branch_manager) = create_test_branch_manager();
240
241        let branch_name = branch_manager
242            .create_branch_from_message("Add login feature", None)
243            .unwrap();
244
245        assert_eq!(branch_name, "add-login-feature");
246        assert!(branch_manager.git_repo().branch_exists(&branch_name));
247    }
248
249    #[test]
250    fn test_branch_info() {
251        let (_temp_dir, branch_manager) = create_test_branch_manager();
252
253        // Create a test branch
254        let _branch_name = branch_manager
255            .create_branch_from_message("Test feature", None)
256            .unwrap();
257
258        let branch_info = branch_manager.get_branch_info().unwrap();
259        assert!(!branch_info.is_empty());
260
261        // Should have at least one branch (the default branch, whether it's "main" or "master")
262        // and at least one should be marked as current
263        assert!(branch_info.iter().any(|b| b.is_current));
264
265        // Should have at least 2 branches (default + the test feature branch we created)
266        assert!(branch_info.len() >= 2);
267
268        // Test that upstream info is included (even if None for test branches)
269        for branch in &branch_info {
270            // In a test environment, branches typically don't have upstream
271            // but the field should exist and be None
272            assert!(branch.upstream.is_none());
273        }
274    }
275
276    #[test]
277    fn test_upstream_parsing() {
278        let (_temp_dir, branch_manager) = create_test_branch_manager();
279
280        // Test parsing upstream names
281        let (remote, branch) = branch_manager
282            .parse_upstream_name("origin/feature-auth")
283            .unwrap();
284        assert_eq!(remote, "origin");
285        assert_eq!(branch, "feature-auth");
286
287        let (remote, branch) = branch_manager.parse_upstream_name("upstream/main").unwrap();
288        assert_eq!(remote, "upstream");
289        assert_eq!(branch, "main");
290
291        // Test fallback for names without slash
292        let (remote, branch) = branch_manager.parse_upstream_name("feature-auth").unwrap();
293        assert_eq!(remote, "origin");
294        assert_eq!(branch, "feature-auth");
295    }
296}