Skip to main content

git_workty/
status.rs

1use crate::git::GitRepo;
2use crate::worktree::Worktree;
3use anyhow::Result;
4use rayon::prelude::*;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Default, Serialize)]
8pub struct WorktreeStatus {
9    pub dirty_count: usize,
10    pub upstream: Option<String>,
11    pub ahead: Option<usize>,
12    pub behind: Option<usize>,
13    /// Seconds since last commit (HEAD)
14    pub last_commit_time: Option<i64>,
15    /// Behind count relative to origin/main or origin/master
16    pub behind_main: Option<usize>,
17    /// Number of commits with no upstream tracking (unpushed branch)
18    pub untracked_commits: Option<usize>,
19    /// True if upstream branch has been deleted on remote
20    pub upstream_gone: bool,
21}
22
23impl WorktreeStatus {
24    pub fn is_dirty(&self) -> bool {
25        self.dirty_count > 0
26    }
27
28    #[allow(dead_code)]
29    pub fn has_upstream(&self) -> bool {
30        self.upstream.is_some()
31    }
32
33    /// Returns true if this branch needs rebasing onto main
34    pub fn needs_rebase(&self) -> bool {
35        self.behind_main.map(|b| b > 0).unwrap_or(false)
36    }
37
38    /// Returns true if there are commits that haven't been pushed
39    pub fn has_unpushed(&self) -> bool {
40        // Commits ahead of upstream
41        if self.ahead.map(|a| a > 0).unwrap_or(false) {
42            return true;
43        }
44        // Branch with no upstream but has commits
45        if self.upstream.is_none() && self.untracked_commits.map(|c| c > 0).unwrap_or(false) {
46            return true;
47        }
48        false
49    }
50
51    /// Returns the number of unpushed commits
52    pub fn unpushed_count(&self) -> usize {
53        if let Some(ahead) = self.ahead {
54            if ahead > 0 {
55                return ahead;
56            }
57        }
58        self.untracked_commits.unwrap_or(0)
59    }
60}
61
62pub fn get_worktree_status(repo: &GitRepo, worktree: &Worktree) -> WorktreeStatus {
63    // Open the worktree repo once and reuse it for all status queries
64    let wt_repo = match git2::Repository::open(&worktree.path) {
65        Ok(r) => r,
66        Err(_) => {
67            return WorktreeStatus::default();
68        }
69    };
70
71    let dirty_count = get_dirty_count(&wt_repo);
72    let (upstream, ahead, behind, upstream_gone) = get_ahead_behind(&wt_repo, worktree);
73    let last_commit_time = get_last_commit_time(&wt_repo);
74    let behind_main = get_behind_main(&wt_repo, repo);
75    let untracked_commits = if upstream.is_none() && !worktree.detached {
76        get_untracked_commit_count(&wt_repo, repo)
77    } else {
78        None
79    };
80
81    WorktreeStatus {
82        dirty_count,
83        upstream,
84        ahead,
85        behind,
86        last_commit_time,
87        behind_main,
88        untracked_commits,
89        upstream_gone,
90    }
91}
92
93fn get_dirty_count(repo: &git2::Repository) -> usize {
94    // Use git2's status API with optimizations for speed
95    let mut opts = git2::StatusOptions::new();
96    opts.include_untracked(true)
97        .recurse_untracked_dirs(false) // Don't recurse - much faster
98        .exclude_submodules(true)
99        .no_refresh(true); // Don't refresh index from disk
100
101    match repo.statuses(Some(&mut opts)) {
102        Ok(statuses) => statuses.len(),
103        Err(_) => 0,
104    }
105}
106
107fn get_ahead_behind(
108    repo: &git2::Repository,
109    worktree: &Worktree,
110) -> (Option<String>, Option<usize>, Option<usize>, bool) {
111    if worktree.detached {
112        return (None, None, None, false);
113    }
114
115    // Get the current branch
116    let head = match repo.head() {
117        Ok(h) => h,
118        Err(_) => return (None, None, None, false),
119    };
120
121    if !head.is_branch() {
122        return (None, None, None, false);
123    }
124
125    let branch_name = match head.shorthand() {
126        Some(name) => name,
127        None => return (None, None, None, false),
128    };
129
130    // Find the local branch and its upstream
131    let branch = match repo.find_branch(branch_name, git2::BranchType::Local) {
132        Ok(b) => b,
133        Err(_) => return (None, None, None, false),
134    };
135
136    let upstream_branch = match branch.upstream() {
137        Ok(u) => u,
138        Err(_) => return (None, None, None, false), // No upstream configured
139    };
140
141    let upstream_name = upstream_branch.name().ok().flatten().map(|s| s.to_string());
142
143    // Get the OIDs for both branches
144    let local_oid = match head.target() {
145        Some(oid) => oid,
146        None => return (upstream_name, None, None, false),
147    };
148
149    let upstream_oid = match upstream_branch.get().target() {
150        Some(oid) => oid,
151        // Upstream ref exists but points to nothing - upstream is gone
152        None => return (upstream_name, None, None, true),
153    };
154
155    // Use git2's graph_ahead_behind - this is the key performance improvement
156    match repo.graph_ahead_behind(local_oid, upstream_oid) {
157        Ok((ahead, behind)) => (upstream_name, Some(ahead), Some(behind), false),
158        // If graph calculation fails, upstream might be gone
159        Err(_) => (upstream_name, None, None, true),
160    }
161}
162
163fn get_last_commit_time(repo: &git2::Repository) -> Option<i64> {
164    let head = repo.head().ok()?;
165    let commit = head.peel_to_commit().ok()?;
166    let time = commit.time();
167    let now = std::time::SystemTime::now()
168        .duration_since(std::time::UNIX_EPOCH)
169        .ok()?
170        .as_secs() as i64;
171    Some(now - time.seconds())
172}
173
174fn get_behind_main(wt_repo: &git2::Repository, main_repo: &GitRepo) -> Option<usize> {
175    // Get HEAD of this worktree
176    let head = wt_repo.head().ok()?;
177    let head_oid = head.target()?;
178
179    // Find origin/main or origin/master in the main repo
180    let main_repo_lock = main_repo.repo.lock().ok()?;
181
182    let main_oid = main_repo_lock
183        .find_reference("refs/remotes/origin/main")
184        .or_else(|_| main_repo_lock.find_reference("refs/remotes/origin/master"))
185        .ok()?
186        .target()?;
187
188    // Calculate how far behind main this branch is
189    // We need to use the worktree repo for the graph calculation
190    // but the OIDs should be the same across repos sharing the same object store
191    match wt_repo.graph_ahead_behind(head_oid, main_oid) {
192        Ok((_ahead, behind)) => Some(behind),
193        Err(_) => None,
194    }
195}
196
197fn get_untracked_commit_count(wt_repo: &git2::Repository, main_repo: &GitRepo) -> Option<usize> {
198    // Count commits on this branch that aren't on origin/main or origin/master
199    // This is for branches with no upstream set
200    let head = wt_repo.head().ok()?;
201    let head_oid = head.target()?;
202
203    let main_repo_lock = main_repo.repo.lock().ok()?;
204
205    let main_oid = main_repo_lock
206        .find_reference("refs/remotes/origin/main")
207        .or_else(|_| main_repo_lock.find_reference("refs/remotes/origin/master"))
208        .ok()?
209        .target()?;
210
211    match wt_repo.graph_ahead_behind(head_oid, main_oid) {
212        Ok((ahead, _behind)) => Some(ahead),
213        Err(_) => None,
214    }
215}
216
217pub fn get_all_statuses(repo: &GitRepo, worktrees: &[Worktree]) -> Vec<(Worktree, WorktreeStatus)> {
218    // Pre-compute main branch OID once for all worktrees
219    let main_oid = get_main_branch_oid(repo);
220
221    worktrees
222        .par_iter()
223        .map(|worktree| {
224            let status = get_worktree_status_full(worktree, main_oid);
225            (worktree.clone(), status)
226        })
227        .collect()
228}
229
230/// Fast version that skips the expensive dirty file check
231pub fn get_all_statuses_fast(
232    repo: &GitRepo,
233    worktrees: &[Worktree],
234) -> Vec<(Worktree, WorktreeStatus)> {
235    let main_oid = get_main_branch_oid(repo);
236
237    worktrees
238        .par_iter()
239        .map(|worktree| {
240            let status = get_worktree_status_minimal(worktree, main_oid);
241            (worktree.clone(), status)
242        })
243        .collect()
244}
245
246fn get_main_branch_oid(repo: &GitRepo) -> Option<git2::Oid> {
247    let repo_lock = repo.repo.lock().ok()?;
248    let reference = repo_lock
249        .find_reference("refs/remotes/origin/main")
250        .or_else(|_| repo_lock.find_reference("refs/remotes/origin/master"))
251        .ok()?;
252    reference.target()
253}
254
255fn get_worktree_status_full(worktree: &Worktree, main_oid: Option<git2::Oid>) -> WorktreeStatus {
256    // Open the worktree repo once and reuse it for all status queries
257    let wt_repo = match git2::Repository::open(&worktree.path) {
258        Ok(r) => r,
259        Err(_) => {
260            return WorktreeStatus::default();
261        }
262    };
263
264    let dirty_count = get_dirty_count(&wt_repo);
265    let (upstream, ahead, behind, upstream_gone) = get_ahead_behind(&wt_repo, worktree);
266    let last_commit_time = get_last_commit_time(&wt_repo);
267
268    // Use pre-computed main_oid for faster calculation
269    let (behind_main, untracked_commits) = if let Some(main_oid) = main_oid {
270        let head_oid = wt_repo.head().ok().and_then(|h| h.target());
271        if let Some(head_oid) = head_oid {
272            let (ahead_of_main, behind_of_main) = wt_repo
273                .graph_ahead_behind(head_oid, main_oid)
274                .unwrap_or((0, 0));
275
276            let untracked = if upstream.is_none() && !worktree.detached {
277                Some(ahead_of_main)
278            } else {
279                None
280            };
281
282            (Some(behind_of_main), untracked)
283        } else {
284            (None, None)
285        }
286    } else {
287        (None, None)
288    };
289
290    WorktreeStatus {
291        dirty_count,
292        upstream,
293        ahead,
294        behind,
295        last_commit_time,
296        behind_main,
297        untracked_commits,
298        upstream_gone,
299    }
300}
301
302/// Minimal status - skips expensive dirty check for fast dashboard
303fn get_worktree_status_minimal(worktree: &Worktree, main_oid: Option<git2::Oid>) -> WorktreeStatus {
304    let wt_repo = match git2::Repository::open(&worktree.path) {
305        Ok(r) => r,
306        Err(_) => {
307            return WorktreeStatus::default();
308        }
309    };
310
311    // Skip dirty check - it's the expensive part
312    let (upstream, ahead, behind, upstream_gone) = get_ahead_behind(&wt_repo, worktree);
313    let last_commit_time = get_last_commit_time(&wt_repo);
314
315    let (behind_main, untracked_commits) = if let Some(main_oid) = main_oid {
316        let head_oid = wt_repo.head().ok().and_then(|h| h.target());
317        if let Some(head_oid) = head_oid {
318            let (ahead_of_main, behind_of_main) = wt_repo
319                .graph_ahead_behind(head_oid, main_oid)
320                .unwrap_or((0, 0));
321
322            let untracked = if upstream.is_none() && !worktree.detached {
323                Some(ahead_of_main)
324            } else {
325                None
326            };
327
328            (Some(behind_of_main), untracked)
329        } else {
330            (None, None)
331        }
332    } else {
333        (None, None)
334    };
335
336    WorktreeStatus {
337        dirty_count: 0, // Skip dirty check in fast mode
338        upstream,
339        ahead,
340        behind,
341        last_commit_time,
342        behind_main,
343        untracked_commits,
344        upstream_gone,
345    }
346}
347
348pub fn is_worktree_dirty(worktree: &Worktree) -> bool {
349    match git2::Repository::open(&worktree.path) {
350        Ok(repo) => get_dirty_count(&repo) > 0,
351        Err(_) => false,
352    }
353}
354
355#[allow(dead_code)]
356pub fn check_branch_merged(repo: &GitRepo, branch: &str, base: &str) -> Result<bool> {
357    repo.is_merged(branch, base)
358}