diff --git a/CHANGELOG.md b/CHANGELOG.md index fb759eb..1bf6e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,21 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Linked worktrees are reported inside their main repo's row, wherever they are checked out: a + worktree holding changes joins the branches in `UNPUSHED` as `branch [n files]`. Inside a repo, + a worktree can be selected, diffed, committed, and its branch pushed. + ### Changed - GitHub actions bumped off the Node 20 runtime. +### Fixed + +- A nested repo, such as a worktree under `.claude/worktrees`, no longer shows as an untracked + `+0 -0` file of the repo around it, and `add --all` no longer stages it as an embedded gitlink. + ## [0.1.0] 2026-09-09 First version. diff --git a/CLAUDE.md b/CLAUDE.md index c1d60a3..0950e6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,9 @@ CI runs `cargo test` alone on ubuntu and macos: no clippy or fmt gate, no `rustf `main.rs` scan + table, `interactive.rs` browser + git writes, `term.rs` raw mode, keys, redraw. `main()`: `collect_repos` walks sequentially and stops at the first `.git`, then `rayon` runs -`scan_repo` per repo (3 git calls each: `diff --numstat HEAD`, `ls-files --others`, `for-each-ref`). -A fourth call multiplies across every repo. +`scan_repo` per repo (3 git calls each: `diff --numstat HEAD`, `ls-files --others`, `for-each-ref`, +plus the first two per linked worktree, listed from `.git/worktrees` without a git call). A fourth +call multiplies across every repo. A linked worktree the walk reaches is mapped to its main repo. Two git paths, on purpose. Reads go through `git()` (`main.rs:248`), which captures stdout and returns `None` on non-zero exit. Writes go through `run_git()` (`interactive.rs:700`), which prints diff --git a/README.md b/README.md index 50ff429..18c5d2f 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,9 @@ z/s move space select a select all d open enter act q quit | Key | In the repo list | Inside a repo | | --- | --- | --- | | `z` / `s`, or up / down | Move the cursor | Move the cursor | -| `space` | Select the repo | Select a file or a branch | +| `space` | Select the repo | Select a file, a worktree or a branch | | `a` | Select or clear every repo | | -| `d`, or right | Open the repo | Show the diff of a file, the whole diff on `all changes`, or the unpushed commits of a branch | +| `d`, or right | Open the repo | Show the diff of a file, the whole diff on `all changes` or a worktree, or the unpushed commits of a branch | | `enter` | Act on the selection | Act on this repo | | `q`, or left | Quit | Back to the list | | `ctrl-c` | Quit | Quit | @@ -142,13 +142,17 @@ orgC/repoE action: [p] push branches [c] commit [b] commit on new branch [q] cancel > ``` -* `p` pushes the selected branches, or every unpushed branch of the selected repos. It then asks - for optional flags: `l` for `--force-with-lease`, `n` for `--no-verify`, `ln` for both. -* `c` commits, `b` creates a branch first. Each repo asks for its own message, shows - `git status --short`, and offers to push afterwards. +* `p` pushes the selected branches and the ones the selected worktrees have checked out, or every + unpushed branch of the selected repos. It then asks for optional flags: `l` for + `--force-with-lease`, `n` for `--no-verify`, `ln` for both. A worktree holds no commits of its + own: its branch is shared with the repo, so it is pushed like any other. +* `c` commits, `b` creates a branch first. Each checkout, the repo or one of its worktrees, asks + for its own message, shows `git status --short`, and offers to push afterwards. With nothing + selected the commit goes through every checkout showing changes; selecting files or worktrees + narrows it to those. * Selected files are staged on their own (`git add -- file`), which is how you commit part of a - repo. With nothing selected the commit stages everything (`git add --all`), and the preview - says which of the two applies. + repo. With nothing selected the commit stages everything (`git add --all`) except nested repos, + which would otherwise land as embedded gitlinks, and the preview says which of the two applies. Every git command is printed before it runs, and git keeps the real terminal, so credential prompts, hooks and progress output behave normally. A repo that becomes clean after an action @@ -162,6 +166,16 @@ ignore, kept only if the file was modified inside the window. A deleted file has own, so its nearest surviving parent directory is used instead: removing an entry updates the mtime of the directory that held it. +**Worktrees**: a repo keeps a single row. Its linked worktrees are read from `.git/worktrees`, so +one checked out under `.claude/worktrees`, next to the repo or outside DIR is found too, and a +worktree the walk reaches on its own is folded into its main repo. `CHANGES` counts the main +checkout only. A worktree holding uncommitted changes joins the branches in `UNPUSHED`, named +after the branch it has checked out (its directory when HEAD is detached) with its file count in +brackets: `OW-701-skill-owasp-mobile [8 files]`, or `feat ahead 2 (new) [3 files]` when that +branch also has unpushed commits. Worktrees come first, then the other branches. A nested repo is +not a change of the repo around it, so its directory is left out of that repo's untracked +files. + **Unpushed**: a local branch whose last commit falls inside the window and which holds commits its remote does not. With an upstream, the count comes from `%(upstream:track)`. Without one, from `git rev-list --count --not --remotes`, so a branch that was never pushed but exists on @@ -178,7 +192,9 @@ differently named remote branch goes where it should. A branch with no upstream files come from `git ls-files --others --exclude-standard`. * Untracked files over 1 MiB count as a changed file but contribute no line count. * Binary files count as a changed file with no lines, as git reports no numbers for them. -* Submodules are not followed: the walk stops at the first `.git` it finds. +* Submodules and nested clones are not followed: the walk stops at the first `.git` it finds. + Linked worktrees are the exception. +* A worktree is committed as a whole (`git add --all`), files inside it cannot be selected. * Below roughly 50 columns the repo column gets short enough to be ambiguous. Nothing wraps or garbles, but a wider terminal is easier to read. * No stash inspection, no hunk staging, no amend. diff --git a/src/interactive.rs b/src/interactive.rs index fddb7fe..8315a02 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -2,6 +2,7 @@ use crate::term::{self, Key, RawTerminal, Screen}; use crate::{ build_table, display_path, format_local, git, local_offset_seconds, repo_label, scan_repo, summary_line, Cell, ChangedFile, Palette, RepoReport, ScanContext, UnpushedBranch, + WorkingTreeChanges, WorktreeReport, }; use std::io::Write; use std::path::Path; @@ -34,12 +35,14 @@ struct PushOptions { enum DetailRow { AllChanges, File(usize), + Worktree(usize), Branch(usize), } struct Selection { repo: bool, files: Vec, + worktrees: Vec, branches: Vec, } @@ -48,11 +51,18 @@ impl Selection { Self { repo: false, files: vec![false; report.changes.files.len()], + worktrees: vec![false; report.worktrees.len()], branches: vec![false; report.unpushed_branches.len()], } } } +/// The checkouts a commit goes through, each asking for its own message. +struct CommitScope { + main_checkout: bool, + worktrees: Vec, +} + struct Browser { reports: Vec, selections: Vec, @@ -151,6 +161,7 @@ impl Browser { match row { DetailRow::AllChanges => selection.repo = !selection.repo, DetailRow::File(index) => selection.files[index] = !selection.files[index], + DetailRow::Worktree(index) => selection.worktrees[index] = !selection.worktrees[index], DetailRow::Branch(index) => selection.branches[index] = !selection.branches[index], } } @@ -177,6 +188,7 @@ impl Browser { rows.push(DetailRow::AllChanges); rows.extend((0..report.changes.files.len()).map(DetailRow::File)); } + rows.extend((0..report.worktrees.len()).map(DetailRow::Worktree)); rows.extend((0..report.unpushed_branches.len()).map(DetailRow::Branch)); if rows.is_empty() { rows.push(DetailRow::AllChanges); @@ -249,14 +261,13 @@ impl Browser { fn summarize(&self, report: &RepoReport) -> String { let mut parts = Vec::new(); if !report.changes.files.is_empty() { + parts.push(self.describe_changes(&report.changes)); + } + if !report.worktrees.is_empty() { parts.push(format!( - "{} file(s) {}+{}{} {}-{}{}", - report.changes.files.len(), - self.palette.green, - report.changes.added_lines(), - self.palette.reset, - self.palette.red, - report.changes.removed_lines(), + "{}{} worktree(s) with changes{}", + self.palette.yellow, + report.worktrees.len(), self.palette.reset )); } @@ -271,6 +282,19 @@ impl Browser { parts.join(" ") } + fn describe_changes(&self, changes: &WorkingTreeChanges) -> String { + format!( + "{} file(s) {}+{}{} {}-{}{}", + changes.files.len(), + self.palette.green, + changes.added_lines(), + self.palette.reset, + self.palette.red, + changes.removed_lines(), + self.palette.reset + ) + } + fn detail_lines( &self, repo: usize, @@ -297,6 +321,10 @@ impl Browser { selection.files[file], self.describe_file(&report.changes.files[file], columns), ), + DetailRow::Worktree(worktree) => ( + selection.worktrees[worktree], + self.describe_worktree_row(&report.worktrees[worktree]), + ), DetailRow::Branch(branch) => ( selection.branches[branch], self.describe_branch_row(&report.unpushed_branches[branch]), @@ -329,6 +357,19 @@ impl Browser { ) } + fn describe_worktree_row(&self, worktree: &WorktreeReport) -> String { + format!( + "{}{}{} worktree {} {}{}{}", + self.palette.yellow, + worktree.name(), + self.palette.reset, + self.describe_changes(&worktree.changes), + self.palette.dim, + display_path(&worktree.path), + self.palette.reset + ) + } + fn describe_branch_row(&self, branch: &UnpushedBranch) -> String { let upstream = match &branch.upstream { Some(upstream) => format!("{}/{}", upstream.remote, branch.name), @@ -364,7 +405,11 @@ impl Browser { } fn show_diff(&mut self, terminal: &RawTerminal, repo: usize, row: DetailRow) { - let path = self.reports[repo].path.clone(); + let report = &self.reports[repo]; + let path = match row { + DetailRow::Worktree(index) => report.worktrees[index].path.clone(), + _ => report.path.clone(), + }; let args = self.diff_args(repo, row); self.screen.clear(); terminal.suspended(|| { @@ -376,7 +421,9 @@ impl Browser { fn diff_args(&self, repo: usize, row: DetailRow) -> Vec { let report = &self.reports[repo]; match row { - DetailRow::AllChanges => vec!["diff".to_string(), "HEAD".to_string()], + DetailRow::AllChanges | DetailRow::Worktree(_) => { + vec!["diff".to_string(), "HEAD".to_string()] + } DetailRow::File(index) => diff_file_args(&report.changes.files[index]), DetailRow::Branch(index) => log_branch_args(&report.unpushed_branches[index]), } @@ -420,19 +467,7 @@ impl Browser { } fn branches_to_push(&self, target: usize) -> Vec<&UnpushedBranch> { - let branches = &self.reports[target].unpushed_branches; - let selection = &self.selections[target].branches; - let chosen: Vec<&UnpushedBranch> = branches - .iter() - .zip(selection) - .filter(|(_, selected)| **selected) - .map(|(branch, _)| branch) - .collect(); - if chosen.is_empty() { - branches.iter().collect() - } else { - chosen - } + push_scope(&self.reports[target], &self.selections[target]) } fn commit_targets(&self, targets: &[usize], on_new_branch: bool) { @@ -442,39 +477,21 @@ impl Browser { } fn commit_repo(&self, target: usize, on_new_branch: bool) { - let repo = &self.reports[target].path; - println!("\n{}", display_path(repo)); - print!("{}", git(repo, &["status", "--short"]).unwrap_or_default()); - - if on_new_branch { - let branch = ask("new branch name? (empty = skip this repo) > "); - if branch.is_empty() { - return; - } - if !run_git(repo, &["checkout".to_string(), "-b".to_string(), branch]) { - return; - } + let report = &self.reports[target]; + let scope = commit_scope(report, &self.selections[target]); + if scope.main_checkout { + commit_checkout(&report.path, &self.stage_args(target), on_new_branch); } - - let message = ask("commit message? (empty = skip this repo) > "); - if message.is_empty() { - println!("skipped"); - return; - } - if !run_git(repo, &self.stage_args(target)) { - return; - } - if !run_git(repo, &["commit".to_string(), "--message".to_string(), message]) { - return; - } - if ask("push now? [y/N] > ").eq_ignore_ascii_case("y") { - push_head(repo, &ask_push_options()); + for &index in &scope.worktrees { + let worktree = &report.worktrees[index]; + let stage_args = stage_all_args(&worktree.changes.nested_repos); + commit_checkout(&worktree.path, &stage_args, on_new_branch); } } fn stage_args(&self, target: usize) -> Vec { if !self.has_selected_files(target) { - return vec!["add".to_string(), "--all".to_string()]; + return stage_all_args(&self.reports[target].changes.nested_repos); } let mut args = vec!["add".to_string(), "--".to_string()]; args.extend(self.files_to_commit(target).iter().map(|file| file.path.clone())); @@ -486,17 +503,40 @@ impl Browser { for &target in targets { let report = &self.reports[target]; println!("\n{}", repo_label(&report.path, &self.context.root)); - self.preview_files(target); + let scope = commit_scope(report, &self.selections[target]); + if scope.main_checkout { + self.preview_files(target); + } + for &index in &scope.worktrees { + self.preview_worktree(&report.worktrees[index]); + } self.preview_branches(target); } } fn preview_files(&self, target: usize) { - let files = self.files_to_commit(target); + let staging = if self.has_selected_files(target) { "selected" } else { "add --all" }; + self.print_files(&self.files_to_commit(target), staging); + } + + fn preview_worktree(&self, worktree: &WorktreeReport) { + println!( + " {}{}{} worktree {}{}{}", + self.palette.yellow, + worktree.name(), + self.palette.reset, + self.palette.dim, + display_path(&worktree.path), + self.palette.reset + ); + let files: Vec<&ChangedFile> = worktree.changes.files.iter().collect(); + self.print_files(&files, "add --all"); + } + + fn print_files(&self, files: &[&ChangedFile], staging: &str) { if files.is_empty() { return; } - let staging = if self.has_selected_files(target) { "selected" } else { "add --all" }; println!(" {} file(s) [{}]", files.len(), staging); for file in files.iter().take(FILES_PREVIEW_LIMIT) { println!( @@ -584,6 +624,87 @@ impl Browser { } } +/// With nothing selected a commit goes through every checkout showing changes, otherwise +/// through the selected files and worktrees only. +fn commit_scope(report: &RepoReport, selection: &Selection) -> CommitScope { + let files_selected = selection.files.contains(&true); + if !files_selected && !selection.worktrees.contains(&true) { + return CommitScope { + main_checkout: !report.changes.files.is_empty(), + worktrees: (0..report.worktrees.len()).collect(), + }; + } + let selected_worktrees = selection + .worktrees + .iter() + .enumerate() + .filter(|(_, selected)| **selected) + .map(|(index, _)| index) + .collect(); + CommitScope { main_checkout: files_selected, worktrees: selected_worktrees } +} + +/// With nothing selected a push covers every unpushed branch, otherwise the selected branches +/// and the ones the selected worktrees have checked out. +fn push_scope<'a>(report: &'a RepoReport, selection: &Selection) -> Vec<&'a UnpushedBranch> { + let nothing_selected = + !selection.branches.contains(&true) && !selection.worktrees.contains(&true); + let held_by_selected_worktree = |branch: &UnpushedBranch| { + report + .worktrees + .iter() + .zip(&selection.worktrees) + .any(|(worktree, selected)| *selected && worktree.holds(branch)) + }; + report + .unpushed_branches + .iter() + .zip(&selection.branches) + .filter(|(branch, selected)| { + nothing_selected || **selected || held_by_selected_worktree(branch) + }) + .map(|(branch, _)| branch) + .collect() +} + +fn commit_checkout(repo: &Path, stage_args: &[String], on_new_branch: bool) { + println!("\n{}", display_path(repo)); + print!("{}", git(repo, &["status", "--short"]).unwrap_or_default()); + + if on_new_branch { + let branch = ask("new branch name? (empty = skip this repo) > "); + if branch.is_empty() { + return; + } + if !run_git(repo, &["checkout".to_string(), "-b".to_string(), branch]) { + return; + } + } + + let message = ask("commit message? (empty = skip this repo) > "); + if message.is_empty() { + println!("skipped"); + return; + } + if !run_git(repo, stage_args) { + return; + } + if !run_git(repo, &["commit".to_string(), "--message".to_string(), message]) { + return; + } + if ask("push now? [y/N] > ").eq_ignore_ascii_case("y") { + push_head(repo, &ask_push_options()); + } +} + +/// `add --all` would record a nested repo as an embedded gitlink, never what a commit of +/// everything means. +fn stage_all_args(nested_repos: &[String]) -> Vec { + let mut args = vec!["add".to_string(), "--all".to_string(), "--".to_string()]; + args.extend(nested_repos.iter().map(|path| format!(":(exclude,literal){path}"))); + args +} + fn diff_file_args(file: &ChangedFile) -> Vec { if file.untracked { // An untracked file has nothing to diff against, so show it whole. @@ -795,6 +916,81 @@ mod tests { ); } + #[test] + fn commits_the_selection_or_every_checkout_with_changes() { + let changes = || { + let file = ChangedFile { + path: "a.rs".into(), + added_lines: 1, + removed_lines: 0, + untracked: false, + deleted: false, + }; + WorkingTreeChanges { files: vec![file], nested_repos: Vec::new(), last_change: None } + }; + let worktree = WorktreeReport { path: "/wt".into(), branch: None, changes: changes() }; + let report = RepoReport { + path: "/repo".into(), + changes: changes(), + unpushed_branches: Vec::new(), + worktrees: vec![worktree], + }; + + let mut selection = Selection::for_report(&report); + let everything = commit_scope(&report, &selection); + assert!(everything.main_checkout && everything.worktrees == [0]); + + selection.worktrees[0] = true; + let worktree_only = commit_scope(&report, &selection); + assert!(!worktree_only.main_checkout && worktree_only.worktrees == [0]); + + selection = Selection::for_report(&report); + selection.files[0] = true; + let files_only = commit_scope(&report, &selection); + assert!(files_only.main_checkout && files_only.worktrees.is_empty()); + } + + #[test] + fn pushes_the_branch_of_a_selected_worktree() { + let no_changes = || WorkingTreeChanges { + files: Vec::new(), + nested_repos: Vec::new(), + last_change: None, + }; + let worktree = WorktreeReport { + path: "/wt".into(), + branch: Some("feat".into()), + changes: no_changes(), + }; + let report = RepoReport { + path: "/repo".into(), + changes: no_changes(), + unpushed_branches: vec![branch("main", None), branch("feat", None)], + worktrees: vec![worktree], + }; + let names = |selection: &Selection| -> Vec { + push_scope(&report, selection).iter().map(|branch| branch.name.clone()).collect() + }; + + let mut selection = Selection::for_report(&report); + assert_eq!(names(&selection), ["main", "feat"]); + selection.worktrees[0] = true; + assert_eq!(names(&selection), ["feat"]); + selection.branches[0] = true; + assert_eq!(names(&selection), ["main", "feat"]); + selection.worktrees[0] = false; + assert_eq!(names(&selection), ["main"]); + } + + #[test] + fn stages_everything_but_nested_repos() { + assert_eq!(stage_all_args(&[]), ["add", "--all", "--"]); + assert_eq!( + stage_all_args(&[".claude/worktrees/[wip]".to_string()]), + ["add", "--all", "--", ":(exclude,literal).claude/worktrees/[wip]"] + ); + } + #[test] fn shows_untracked_files_whole() { let untracked = diff --git a/src/main.rs b/src/main.rs index 158a054..730bc36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,6 +80,7 @@ impl ChangedFile { struct WorkingTreeChanges { files: Vec, + nested_repos: Vec, last_change: Option, } @@ -97,16 +98,54 @@ struct RepoReport { path: PathBuf, changes: WorkingTreeChanges, unpushed_branches: Vec, + worktrees: Vec, +} + +/// A linked worktree holding uncommitted changes, reported inside its main repo. +struct WorktreeReport { + path: PathBuf, + branch: Option, + changes: WorkingTreeChanges, +} + +impl WorktreeReport { + /// A detached worktree has no branch to name, so its directory stands in. + fn name(&self) -> String { + match &self.branch { + Some(branch) => branch.clone(), + None => self.path.file_name().unwrap_or_default().to_string_lossy().into_owned(), + } + } + + fn holds(&self, branch: &UnpushedBranch) -> bool { + self.branch.as_deref() == Some(branch.name.as_str()) + } +} + +/// A linked worktree as its main repo records it, under `.git/worktrees/`. +struct WorktreeEntry { + git_dir: PathBuf, + path: PathBuf, } impl RepoReport { fn is_clean(&self) -> bool { - self.changes.files.is_empty() && self.unpushed_branches.is_empty() + self.changes.files.is_empty() + && self.unpushed_branches.is_empty() + && self.worktrees.is_empty() } fn last_activity(&self) -> Option { let last_commit = self.unpushed_branches.iter().map(|branch| branch.last_commit).max(); - self.changes.last_change.max(last_commit) + let last_worktree_change = + self.worktrees.iter().filter_map(|worktree| worktree.changes.last_change).max(); + self.changes.last_change.max(last_commit).max(last_worktree_change) + } + + fn changed_file_count(&self) -> usize { + let in_worktrees: usize = + self.worktrees.iter().map(|worktree| worktree.changes.files.len()).sum(); + self.changes.files.len() + in_worktrees } } @@ -165,8 +204,12 @@ fn main() { let cutoff = now_unix().saturating_sub(period_seconds); let started = Instant::now(); + // Git records worktree paths resolved, so the root is too, or the two would not compare. + let root = std::fs::canonicalize(&options.root).unwrap_or(options.root); let mut repos = Vec::new(); - collect_repos(&options.root, MAX_SCAN_DEPTH, &mut repos); + collect_repos(&root, MAX_SCAN_DEPTH, &mut repos); + repos.sort(); + repos.dedup(); let mut reports: Vec = repos .par_iter() .map(|repo| scan_repo(repo, cutoff)) @@ -175,7 +218,7 @@ fn main() { reports.sort_by_key(|report| std::cmp::Reverse(report.last_activity())); let context = ScanContext { - root: options.root, + root, cutoff, scanned: repos.len(), period: options.period, @@ -230,7 +273,8 @@ fn collect_repos(dir: &Path, depth: usize, found: &mut Vec) { return; } if dir.join(".git").exists() { - found.push(dir.to_path_buf()); + // A linked worktree is reported inside its main repo, which lists every worktree. + found.push(main_repo(dir).unwrap_or_else(|| dir.to_path_buf())); return; } let Ok(entries) = std::fs::read_dir(dir) else { @@ -250,6 +294,46 @@ fn collect_repos(dir: &Path, depth: usize, found: &mut Vec) { } } +/// Worktrees are found through the repo that owns them, wherever they are checked out: the walk +/// skips dotted directories such as `.claude/worktrees` and never enters a repo. +fn worktree_entries(repo: &Path) -> Vec { + // A bare repo is its own git dir. + let git_dir = if repo.join(".git").is_dir() { repo.join(".git") } else { repo.to_path_buf() }; + let Ok(entries) = std::fs::read_dir(git_dir.join("worktrees")) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let git_dir = entry.path(); + let pointer = std::fs::read_to_string(git_dir.join("gitdir")).ok()?; + // Fails on a worktree deleted without `git worktree remove`, which drops it. + let dot_git = std::fs::canonicalize(git_dir.join(pointer.trim_end())).ok()?; + Some(WorktreeEntry { path: dot_git.parent()?.to_path_buf(), git_dir }) + }) + .collect() +} + +/// A linked worktree has a `.git` file pointing at a git dir that holds `commondir`, where a +/// submodule's points at one that does not. +fn main_repo(repo: &Path) -> Option { + let pointer = std::fs::read_to_string(repo.join(".git")).ok()?; + let git_dir = repo.join(pointer.trim_end().strip_prefix("gitdir: ")?); + let common_dir = std::fs::read_to_string(git_dir.join("commondir")).ok()?; + let main_git_dir = std::fs::canonicalize(git_dir.join(common_dir.trim_end())).ok()?; + // A bare repo is its own git dir, with no working tree around it. + if !main_git_dir.ends_with(".git") { + return Some(main_git_dir); + } + main_git_dir.parent().map(Path::to_path_buf) +} + +fn checked_out_branch(git_dir: &Path) -> Option { + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + head.trim_end().strip_prefix("ref: refs/heads/").map(str::to_string) +} + + fn git(repo: &Path, args: &[&str]) -> Option { let output = Command::new("git") .arg("-C") @@ -267,15 +351,31 @@ fn git(repo: &Path, args: &[&str]) -> Option { } fn scan_repo(repo: &Path, cutoff: u64) -> RepoReport { + let mut worktrees: Vec = worktree_entries(repo) + .iter() + .map(|entry| scan_worktree(entry, cutoff)) + .filter(|worktree| !worktree.changes.files.is_empty()) + .collect(); + worktrees.sort_by_key(|worktree| std::cmp::Reverse(worktree.changes.last_change)); RepoReport { path: repo.to_path_buf(), changes: scan_working_tree(repo, cutoff), unpushed_branches: scan_branches(repo, cutoff), + worktrees, + } +} + +fn scan_worktree(entry: &WorktreeEntry, cutoff: u64) -> WorktreeReport { + WorktreeReport { + path: entry.path.clone(), + branch: checked_out_branch(&entry.git_dir), + changes: scan_working_tree(&entry.path, cutoff), } } fn scan_working_tree(repo: &Path, cutoff: u64) -> WorkingTreeChanges { - let mut changes = WorkingTreeChanges { files: Vec::new(), last_change: None }; + let mut changes = + WorkingTreeChanges { files: Vec::new(), nested_repos: Vec::new(), last_change: None }; let tracked = git(repo, &["diff", "--numstat", "--no-renames", "-z", "HEAD"]).unwrap_or_default(); @@ -302,6 +402,11 @@ fn scan_working_tree(repo: &Path, cutoff: u64) -> WorkingTreeChanges { let untracked = git(repo, &["ls-files", "--others", "--exclude-standard", "-z"]).unwrap_or_default(); for path in split_nul(&untracked) { + // A trailing slash is how git lists a nested repo, such as a worktree, without entering it. + if let Some(nested_repo) = path.strip_suffix('/') { + changes.nested_repos.push(nested_repo.to_string()); + continue; + } let full_path = repo.join(path); let Some(changed_at) = change_time(&full_path, repo, cutoff) else { continue; @@ -506,7 +611,7 @@ fn table_row( let mut cells = vec![ Cell::plain(truncate_start(&repo_label(&report.path, root), layout.limits.repo)), changes_cell(&report.changes, palette), - unpushed_cell(&report.unpushed_branches, palette, layout.limits.unpushed), + unpushed_cell(report, palette, layout.limits.unpushed), ]; if layout.with_date { let last_activity = @@ -549,17 +654,37 @@ fn changes_text(changes: &WorkingTreeChanges) -> String { ) } -fn unpushed_cell(branches: &[UnpushedBranch], palette: &Palette, max_width: usize) -> Cell { - if branches.is_empty() { +fn unpushed_cell(report: &RepoReport, palette: &Palette, max_width: usize) -> Cell { + if report.unpushed_branches.is_empty() && report.worktrees.is_empty() { return Cell::plain(EMPTY_CELL); } - let plain = truncate_end(&unpushed_text(branches, max_width), max_width); + let plain = truncate_end(&unpushed_text(report, max_width), max_width); Cell::colored(plain.clone(), format!("{}{}{}", palette.yellow, plain, palette.reset)) } -fn unpushed_text(branches: &[UnpushedBranch], max_width: usize) -> String { - let labels: Vec = branches.iter().map(describe_branch).collect(); - join_within_width(&labels, max_width) +fn unpushed_text(report: &RepoReport, max_width: usize) -> String { + join_within_width(&unpushed_labels(report), max_width) +} + +/// Worktrees first, each with the unpushed commits of its branch, then the branches no listed +/// worktree has checked out. +fn unpushed_labels(report: &RepoReport) -> Vec { + let worktrees = report.worktrees.iter().map(|worktree| { + let branch = report.unpushed_branches.iter().find(|branch| worktree.holds(branch)); + describe_worktree(worktree, branch) + }); + let branches = report + .unpushed_branches + .iter() + .filter(|branch| !report.worktrees.iter().any(|worktree| worktree.holds(branch))) + .map(describe_branch); + worktrees.chain(branches).collect() +} + +fn describe_worktree(worktree: &WorktreeReport, branch: Option<&UnpushedBranch>) -> String { + let count = worktree.changes.files.len(); + let name = branch.map(describe_branch).unwrap_or_else(|| worktree.name()); + format!("{name} [{count} file{}]", plural(count)) } struct ColumnLimits { @@ -591,7 +716,7 @@ fn table_layout(reports: &[RepoReport], context: &ScanContext, has_prefix: bool) ); let natural_unpushed = widest( reports.iter().map(|report| { - unpushed_text(&report.unpushed_branches, UNPUSHED_CELL_MAX_WIDTH).chars().count() + unpushed_text(report, UNPUSHED_CELL_MAX_WIDTH).chars().count() }), TABLE_HEADERS[2], ); @@ -704,7 +829,7 @@ fn plural(count: usize) -> &'static str { } fn summary_line(reports: &[RepoReport], context: &ScanContext, palette: &Palette) -> String { - let changed_files: usize = reports.iter().map(|report| report.changes.files.len()).sum(); + let changed_files: usize = reports.iter().map(RepoReport::changed_file_count).sum(); let unpushed_branches: usize = reports.iter().map(|report| report.unpushed_branches.len()).sum(); let full = format!( "{} repo(s) with activity in the last {} out of {} scanned: {} file(s), {} unpushed branch(es), {:.0?}", @@ -807,6 +932,44 @@ fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { mod tests { use super::*; + fn no_changes() -> WorkingTreeChanges { + WorkingTreeChanges { files: Vec::new(), nested_repos: Vec::new(), last_change: None } + } + + fn one_file() -> WorkingTreeChanges { + let file = ChangedFile { + path: "a.rs".to_string(), + added_lines: 1, + removed_lines: 0, + untracked: false, + deleted: false, + }; + WorkingTreeChanges { files: vec![file], ..no_changes() } + } + + fn unpushed(name: &str) -> UnpushedBranch { + UnpushedBranch { name: name.to_string(), ahead: 2, last_commit: 0, upstream: None } + } + + #[test] + fn lists_worktrees_with_the_branches() { + let worktree = |branch: Option<&str>, path: &str| WorktreeReport { + path: PathBuf::from(path), + branch: branch.map(str::to_string), + changes: one_file(), + }; + let report = RepoReport { + path: PathBuf::from("/dev/claude"), + changes: no_changes(), + unpushed_branches: vec![unpushed("main"), unpushed("feat")], + worktrees: vec![worktree(Some("feat"), "/dev/claude-feat"), worktree(None, "/dev/wip")], + }; + assert_eq!( + unpushed_labels(&report), + ["feat ahead 2 (new) [1 file]", "wip [1 file]", "main ahead 2 (new)"] + ); + } + #[test] fn never_hands_out_more_width_than_it_has() { assert_eq!(split_flexible_width(100, 20, 30), (20, 30));