From 7034345ee7ac33acb8b9a98cad40a0ea8549363d Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 29 Sep 2026 16:34:15 +0200 Subject: [PATCH 1/4] Give linked worktrees their own row A worktree checked out under .claude/worktrees was never scanned: the walk skips dotted directories and stops at the first .git. Its parent listed it as an untracked +0 -0 file instead, and a commit with nothing selected ran git add --all, which records it as an embedded gitlink. - Worktrees are read from the repo's .git/worktrees, wherever they are checked out, and deduplicated against the walk. - A worktree row carries its uncommitted changes only: branches are shared and stay on the main repo's row. - A nested repo is left out of its parent's untracked files, and add --all excludes it. --- CHANGELOG.md | 10 +++++++++ README.md | 14 +++++++++--- src/interactive.rs | 19 ++++++++++++++++- src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb759eb..bf82e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,20 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Linked worktrees get their own row with their uncommitted changes, wherever they are checked + out. Their branches stay on the main repo's row. + ### 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/README.md b/README.md index 50ff429..ba242f3 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ action: [p] push branches [c] commit [b] commit on new branch [q] cancel > * `c` commits, `b` creates a branch first. Each repo asks for its own message, shows `git status --short`, and offers to push afterwards. * 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 +162,12 @@ 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**: each linked worktree of a repo gets its own row, found through the repo's +`.git/worktrees`, so one checked out under `.claude/worktrees` or next to the repo shows up too. +Its row holds its uncommitted changes only: branches are shared, and the main repo's row lists +them. 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 +184,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 scanned without its main repo (DIR covers the worktree only) shows no branches. * 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..c7aa7cf 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -474,7 +474,7 @@ impl Browser { 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())); @@ -584,6 +584,14 @@ impl Browser { } } +/// `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 +803,15 @@ mod tests { ); } + #[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..3d4d755 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, } @@ -165,8 +166,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 +180,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, @@ -231,6 +236,7 @@ fn collect_repos(dir: &Path, depth: usize, found: &mut Vec) { } if dir.join(".git").exists() { found.push(dir.to_path_buf()); + found.extend(linked_worktrees(dir)); return; } let Ok(entries) = std::fs::read_dir(dir) else { @@ -250,6 +256,35 @@ 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 linked_worktrees(repo: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(repo.join(".git").join("worktrees")) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let pointer = std::fs::read_to_string(entry.path().join("gitdir")).ok()?; + // Fails on a worktree deleted without `git worktree remove`, which drops it. + let dot_git = std::fs::canonicalize(entry.path().join(pointer.trim_end())).ok()?; + dot_git.parent().map(Path::to_path_buf) + }) + .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 is_linked_worktree(repo: &Path) -> bool { + let Ok(pointer) = std::fs::read_to_string(repo.join(".git")) else { + return false; + }; + pointer + .trim_end() + .strip_prefix("gitdir: ") + .is_some_and(|git_dir| repo.join(git_dir).join("commondir").exists()) +} + fn git(repo: &Path, args: &[&str]) -> Option { let output = Command::new("git") .arg("-C") @@ -267,15 +302,20 @@ fn git(repo: &Path, args: &[&str]) -> Option { } fn scan_repo(repo: &Path, cutoff: u64) -> RepoReport { + // Branches belong to the main repo, whose row lists them. A worktree scanned without its + // main repo therefore shows none: widen DIR to include the main repo. + let unpushed_branches = + if is_linked_worktree(repo) { Vec::new() } else { scan_branches(repo, cutoff) }; RepoReport { path: repo.to_path_buf(), changes: scan_working_tree(repo, cutoff), - unpushed_branches: scan_branches(repo, cutoff), + unpushed_branches, } } 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 +342,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; From 26815c7c93a34d71071208a256a64a05ed724caa Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 29 Sep 2026 17:19:42 +0200 Subject: [PATCH 2/4] Show each worktree as a branch of its repo A worktree row was labelled by its directory, so claude-OW-701 or .claude/worktrees/tech-writer did not say which repo it belonged to, and its branch sat on the main repo's row, away from the changes made on it. - The row is named after the main repo and the checked out branch: ownweb/claude [OW-701-skill-owasp-mobile]. A detached worktree falls back to its directory name, a bare repo to its own directory. - A worktree row lists the unpushed commits of its own branch. The main repo keeps every branch no live worktree has checked out. - Both are read from the .git files and .git/worktrees, still three git calls per row. --- CHANGELOG.md | 6 +- README.md | 11 ++-- src/interactive.rs | 4 +- src/main.rs | 142 ++++++++++++++++++++++++++++++++++++++------- 4 files changed, 133 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf82e38..cf34acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- Linked worktrees get their own row with their uncommitted changes, wherever they are checked - out. Their branches stay on the main repo's row. +- Linked worktrees get their own row, wherever they are checked out, named after the main repo + and the branch they have checked out (`repo [branch]`). The row holds the worktree's + uncommitted changes and that branch's unpushed commits; other branches stay on the main repo's + row. ### Changed diff --git a/README.md b/README.md index ba242f3..627343a 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,11 @@ mtime of the directory that held it. **Worktrees**: each linked worktree of a repo gets its own row, found through the repo's `.git/worktrees`, so one checked out under `.claude/worktrees` or next to the repo shows up too. -Its row holds its uncommitted changes only: branches are shared, and the main repo's row lists -them. A nested repo is not a change of the repo around it, so its directory is left out of that -repo's untracked files. +The row is named after the main repo and the branch the worktree has checked out +(`ownweb/claude [OW-701-skill-owasp-mobile]`), or its directory when HEAD is detached. It holds +the worktree's uncommitted changes and that branch's unpushed commits; every other branch stays on +the main repo's row. 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 @@ -186,7 +188,8 @@ differently named remote branch goes where it should. A branch with no upstream * Binary files count as a changed file with no lines, as git reports no numbers for them. * Submodules and nested clones are not followed: the walk stops at the first `.git` it finds. Linked worktrees are the exception. -* A worktree scanned without its main repo (DIR covers the worktree only) shows no branches. +* A worktree scanned without its main repo (DIR covers the worktree only) shows its own branch, + not the repo's other ones. * 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 c7aa7cf..7f3f820 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -285,7 +285,7 @@ impl Browser { let header = format!( "{}{}{}", self.palette.bold, - repo_label(&report.path, &self.context.root), + repo_label(report, &self.context.root), self.palette.reset ); let mut lines = vec![header]; @@ -485,7 +485,7 @@ impl Browser { fn preview(&self, targets: &[usize]) { for &target in targets { let report = &self.reports[target]; - println!("\n{}", repo_label(&report.path, &self.context.root)); + println!("\n{}", repo_label(report, &self.context.root)); self.preview_files(target); self.preview_branches(target); } diff --git a/src/main.rs b/src/main.rs index 3d4d755..6a8619b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,6 +98,35 @@ struct RepoReport { path: PathBuf, changes: WorkingTreeChanges, unpushed_branches: Vec, + worktree: Option, +} + +/// A linked worktree, labelled after the repo it belongs to and the branch it has checked out. +struct Worktree { + main_repo: PathBuf, + branch: Option, +} + +/// A linked worktree as its main repo records it, under `.git/worktrees/`. +struct WorktreeEntry { + git_dir: PathBuf, + path: PathBuf, +} + +/// Which branches a row lists: a linked worktree the one it has checked out, the main repo +/// every other one. +enum BranchScope { + CheckedOut(Option), + AllBut(Vec), +} + +impl BranchScope { + fn includes(&self, name: &str) -> bool { + match self { + Self::CheckedOut(branch) => branch.as_deref() == Some(name), + Self::AllBut(elsewhere) => !elsewhere.iter().any(|branch| branch == name), + } + } } impl RepoReport { @@ -236,7 +265,7 @@ fn collect_repos(dir: &Path, depth: usize, found: &mut Vec) { } if dir.join(".git").exists() { found.push(dir.to_path_buf()); - found.extend(linked_worktrees(dir)); + found.extend(worktree_entries(dir).into_iter().map(|entry| entry.path)); return; } let Ok(entries) = std::fs::read_dir(dir) else { @@ -258,31 +287,54 @@ 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 linked_worktrees(repo: &Path) -> Vec { +fn worktree_entries(repo: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(repo.join(".git").join("worktrees")) else { return Vec::new(); }; entries .flatten() .filter_map(|entry| { - let pointer = std::fs::read_to_string(entry.path().join("gitdir")).ok()?; + 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(entry.path().join(pointer.trim_end())).ok()?; - dot_git.parent().map(Path::to_path_buf) + 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 is_linked_worktree(repo: &Path) -> bool { - let Ok(pointer) = std::fs::read_to_string(repo.join(".git")) else { - return false; +fn linked_worktree(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. + let main_repo = if main_git_dir.ends_with(".git") { + main_git_dir.parent()?.to_path_buf() + } else { + main_git_dir }; - pointer - .trim_end() - .strip_prefix("gitdir: ") - .is_some_and(|git_dir| repo.join(git_dir).join("commondir").exists()) + Some(Worktree { main_repo, branch: checked_out_branch(&git_dir) }) +} + +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) +} + +/// A worktree scanned without its main repo lists its own branch only: widen DIR to include +/// the main repo for the others. +fn branch_scope(repo: &Path, worktree: Option<&Worktree>) -> BranchScope { + if let Some(worktree) = worktree { + return BranchScope::CheckedOut(worktree.branch.clone()); + } + let checked_out_elsewhere = worktree_entries(repo) + .iter() + .filter_map(|entry| checked_out_branch(&entry.git_dir)) + .collect(); + BranchScope::AllBut(checked_out_elsewhere) } fn git(repo: &Path, args: &[&str]) -> Option { @@ -302,14 +354,13 @@ fn git(repo: &Path, args: &[&str]) -> Option { } fn scan_repo(repo: &Path, cutoff: u64) -> RepoReport { - // Branches belong to the main repo, whose row lists them. A worktree scanned without its - // main repo therefore shows none: widen DIR to include the main repo. - let unpushed_branches = - if is_linked_worktree(repo) { Vec::new() } else { scan_branches(repo, cutoff) }; + let worktree = linked_worktree(repo); + let scope = branch_scope(repo, worktree.as_ref()); RepoReport { path: repo.to_path_buf(), changes: scan_working_tree(repo, cutoff), - unpushed_branches, + unpushed_branches: scan_branches(repo, cutoff, &scope), + worktree, } } @@ -395,9 +446,12 @@ fn count_lines(path: &Path) -> u64 { .unwrap_or(0) } -fn scan_branches(repo: &Path, cutoff: u64) -> Vec { +fn scan_branches(repo: &Path, cutoff: u64, scope: &BranchScope) -> Vec { let refs = git(repo, &["for-each-ref", BRANCH_FORMAT, "refs/heads"]).unwrap_or_default(); - refs.lines().filter_map(|line| parse_branch(repo, line, cutoff)).collect() + refs.lines() + .filter(|line| scope.includes(line.split('\t').next().unwrap_or_default())) + .filter_map(|line| parse_branch(repo, line, cutoff)) + .collect() } fn parse_branch(repo: &Path, line: &str, cutoff: u64) -> Option { @@ -437,7 +491,19 @@ fn count_commits_missing_from_remotes(repo: &Path, branch: &str) -> u64 { .unwrap_or(0) } -fn repo_label(path: &Path, root: &Path) -> String { +fn repo_label(report: &RepoReport, root: &Path) -> String { + let Some(worktree) = &report.worktree else { + return path_label(&report.path, root); + }; + // A detached worktree has no branch to name, so its directory stands in. + let checkout = match &worktree.branch { + Some(branch) => branch.clone(), + None => report.path.file_name().unwrap_or_default().to_string_lossy().into_owned(), + }; + format!("{} [{checkout}]", path_label(&worktree.main_repo, root)) +} + +fn path_label(path: &Path, root: &Path) -> String { path.strip_prefix(root) .ok() .filter(|relative| !relative.as_os_str().is_empty()) @@ -549,7 +615,7 @@ fn table_row( layout: &TableLayout, ) -> Vec { let mut cells = vec![ - Cell::plain(truncate_start(&repo_label(&report.path, root), layout.limits.repo)), + Cell::plain(truncate_start(&repo_label(report, root), layout.limits.repo)), changes_cell(&report.changes, palette), unpushed_cell(&report.unpushed_branches, palette, layout.limits.unpushed), ]; @@ -631,7 +697,7 @@ impl TableLayout { /// shortenable columns can no longer hold their minimum, then names get trimmed. fn table_layout(reports: &[RepoReport], context: &ScanContext, has_prefix: bool) -> TableLayout { let natural_repo = widest( - reports.iter().map(|report| repo_label(&report.path, &context.root).chars().count()), + reports.iter().map(|report| repo_label(report, &context.root).chars().count()), TABLE_HEADERS[0], ); let natural_unpushed = widest( @@ -852,6 +918,38 @@ fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { mod tests { use super::*; + fn worktree_report(branch: Option<&str>) -> RepoReport { + let changes = + WorkingTreeChanges { files: Vec::new(), nested_repos: Vec::new(), last_change: None }; + RepoReport { + path: PathBuf::from("/dev/claude-wt"), + changes, + unpushed_branches: Vec::new(), + worktree: Some(Worktree { + main_repo: PathBuf::from("/dev/claude"), + branch: branch.map(str::to_string), + }), + } + } + + #[test] + fn gives_each_checked_out_branch_to_its_worktree() { + let worktree = BranchScope::CheckedOut(Some("feat".to_string())); + assert!(worktree.includes("feat")); + assert!(!worktree.includes("main")); + assert!(!BranchScope::CheckedOut(None).includes("main")); + let main_repo = BranchScope::AllBut(vec!["feat".to_string()]); + assert!(main_repo.includes("main")); + assert!(!main_repo.includes("feat")); + } + + #[test] + fn labels_a_worktree_after_its_repo_and_branch() { + let root = Path::new("/dev"); + assert_eq!(repo_label(&worktree_report(Some("OW-701")), root), "claude [OW-701]"); + assert_eq!(repo_label(&worktree_report(None), root), "claude [claude-wt]"); + } + #[test] fn never_hands_out_more_width_than_it_has() { assert_eq!(split_flexible_width(100, 20, 30), (20, 30)); From a0e2d33d1e5df68f96a686e799c1fa3b3db95b08 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 29 Sep 2026 19:24:33 +0200 Subject: [PATCH 3/4] Fold worktrees into their main repo's row One row per worktree still read as several repos: ownweb/claude showed up three times. A repo now keeps a single row, and its worktrees are listed with its branches, which is what they are. - A linked worktree the walk reaches is mapped to its main repo, which scans every worktree listed in .git/worktrees. A worktree outside DIR is found through its repo, so the worktree without main repo case is gone. - UNPUSHED lists worktrees holding changes first, as branch [n files], merged with that branch's unpushed commits when it has some, then the other branches. CHANGES stays the main checkout's. - Inside a repo a worktree is a row: space selects it, d shows its diff, and commit goes through every checkout showing changes, or the selected files and worktrees only, each asking for its own message. --- CHANGELOG.md | 7 +- CLAUDE.md | 5 +- README.md | 29 +++--- src/interactive.rs | 216 +++++++++++++++++++++++++++++++++++-------- src/main.rs | 224 ++++++++++++++++++++++++--------------------- 5 files changed, 320 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf34acc..c5e97f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,9 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added -- Linked worktrees get their own row, wherever they are checked out, named after the main repo - and the branch they have checked out (`repo [branch]`). The row holds the worktree's - uncommitted changes and that branch's unpushed commits; other branches stay on the main repo's - row. +- 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 and committed. ### Changed 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 627343a..4a99717 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 | @@ -144,8 +144,10 @@ 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. +* `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`) except nested repos, which would otherwise land as embedded gitlinks, and the preview says which of the two applies. @@ -162,13 +164,15 @@ 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**: each linked worktree of a repo gets its own row, found through the repo's -`.git/worktrees`, so one checked out under `.claude/worktrees` or next to the repo shows up too. -The row is named after the main repo and the branch the worktree has checked out -(`ownweb/claude [OW-701-skill-owasp-mobile]`), or its directory when HEAD is detached. It holds -the worktree's uncommitted changes and that branch's unpushed commits; every other branch stays on -the main repo's row. A nested repo is not a change of the repo around it, so its directory is left -out of that repo's untracked files. +**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 @@ -188,8 +192,7 @@ differently named remote branch goes where it should. A branch with no upstream * Binary files count as a changed file with no lines, as git reports no numbers for them. * Submodules and nested clones are not followed: the walk stops at the first `.git` it finds. Linked worktrees are the exception. -* A worktree scanned without its main repo (DIR covers the worktree only) shows its own branch, - not the repo's other ones. +* 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 7f3f820..09106d1 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, @@ -285,7 +309,7 @@ impl Browser { let header = format!( "{}{}{}", self.palette.bold, - repo_label(report, &self.context.root), + repo_label(&report.path, &self.context.root), self.palette.reset ); let mut lines = vec![header]; @@ -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]), } @@ -442,33 +489,15 @@ 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 message = ask("commit message? (empty = skip this repo) > "); - if message.is_empty() { - println!("skipped"); - 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); } - 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); } } @@ -485,18 +514,41 @@ impl Browser { fn preview(&self, targets: &[usize]) { for &target in targets { let report = &self.reports[target]; - println!("\n{}", repo_label(report, &self.context.root)); - self.preview_files(target); + println!("\n{}", repo_label(&report.path, &self.context.root)); + 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 +636,56 @@ 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 } +} + +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 { @@ -803,6 +905,40 @@ 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 stages_everything_but_nested_repos() { assert_eq!(stage_all_args(&[]), ["add", "--all", "--"]); diff --git a/src/main.rs b/src/main.rs index 6a8619b..730bc36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -98,13 +98,28 @@ struct RepoReport { path: PathBuf, changes: WorkingTreeChanges, unpushed_branches: Vec, - worktree: Option, + worktrees: Vec, } -/// A linked worktree, labelled after the repo it belongs to and the branch it has checked out. -struct Worktree { - main_repo: PathBuf, +/// 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/`. @@ -113,30 +128,24 @@ struct WorktreeEntry { path: PathBuf, } -/// Which branches a row lists: a linked worktree the one it has checked out, the main repo -/// every other one. -enum BranchScope { - CheckedOut(Option), - AllBut(Vec), -} - -impl BranchScope { - fn includes(&self, name: &str) -> bool { - match self { - Self::CheckedOut(branch) => branch.as_deref() == Some(name), - Self::AllBut(elsewhere) => !elsewhere.iter().any(|branch| branch == name), - } - } -} - 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 } } @@ -264,8 +273,8 @@ fn collect_repos(dir: &Path, depth: usize, found: &mut Vec) { return; } if dir.join(".git").exists() { - found.push(dir.to_path_buf()); - found.extend(worktree_entries(dir).into_iter().map(|entry| entry.path)); + // 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 { @@ -288,7 +297,9 @@ 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 { - let Ok(entries) = std::fs::read_dir(repo.join(".git").join("worktrees")) else { + // 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 @@ -305,18 +316,16 @@ fn worktree_entries(repo: &Path) -> Vec { /// 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 linked_worktree(repo: &Path) -> Option { +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. - let main_repo = if main_git_dir.ends_with(".git") { - main_git_dir.parent()?.to_path_buf() - } else { - main_git_dir - }; - Some(Worktree { main_repo, branch: checked_out_branch(&git_dir) }) + 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 { @@ -324,18 +333,6 @@ fn checked_out_branch(git_dir: &Path) -> Option { head.trim_end().strip_prefix("ref: refs/heads/").map(str::to_string) } -/// A worktree scanned without its main repo lists its own branch only: widen DIR to include -/// the main repo for the others. -fn branch_scope(repo: &Path, worktree: Option<&Worktree>) -> BranchScope { - if let Some(worktree) = worktree { - return BranchScope::CheckedOut(worktree.branch.clone()); - } - let checked_out_elsewhere = worktree_entries(repo) - .iter() - .filter_map(|entry| checked_out_branch(&entry.git_dir)) - .collect(); - BranchScope::AllBut(checked_out_elsewhere) -} fn git(repo: &Path, args: &[&str]) -> Option { let output = Command::new("git") @@ -354,13 +351,25 @@ fn git(repo: &Path, args: &[&str]) -> Option { } fn scan_repo(repo: &Path, cutoff: u64) -> RepoReport { - let worktree = linked_worktree(repo); - let scope = branch_scope(repo, worktree.as_ref()); + 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, &scope), - worktree, + 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), } } @@ -446,12 +455,9 @@ fn count_lines(path: &Path) -> u64 { .unwrap_or(0) } -fn scan_branches(repo: &Path, cutoff: u64, scope: &BranchScope) -> Vec { +fn scan_branches(repo: &Path, cutoff: u64) -> Vec { let refs = git(repo, &["for-each-ref", BRANCH_FORMAT, "refs/heads"]).unwrap_or_default(); - refs.lines() - .filter(|line| scope.includes(line.split('\t').next().unwrap_or_default())) - .filter_map(|line| parse_branch(repo, line, cutoff)) - .collect() + refs.lines().filter_map(|line| parse_branch(repo, line, cutoff)).collect() } fn parse_branch(repo: &Path, line: &str, cutoff: u64) -> Option { @@ -491,19 +497,7 @@ fn count_commits_missing_from_remotes(repo: &Path, branch: &str) -> u64 { .unwrap_or(0) } -fn repo_label(report: &RepoReport, root: &Path) -> String { - let Some(worktree) = &report.worktree else { - return path_label(&report.path, root); - }; - // A detached worktree has no branch to name, so its directory stands in. - let checkout = match &worktree.branch { - Some(branch) => branch.clone(), - None => report.path.file_name().unwrap_or_default().to_string_lossy().into_owned(), - }; - format!("{} [{checkout}]", path_label(&worktree.main_repo, root)) -} - -fn path_label(path: &Path, root: &Path) -> String { +fn repo_label(path: &Path, root: &Path) -> String { path.strip_prefix(root) .ok() .filter(|relative| !relative.as_os_str().is_empty()) @@ -615,9 +609,9 @@ fn table_row( layout: &TableLayout, ) -> Vec { let mut cells = vec![ - Cell::plain(truncate_start(&repo_label(report, root), layout.limits.repo)), + 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 = @@ -660,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 { @@ -697,12 +711,12 @@ impl TableLayout { /// shortenable columns can no longer hold their minimum, then names get trimmed. fn table_layout(reports: &[RepoReport], context: &ScanContext, has_prefix: bool) -> TableLayout { let natural_repo = widest( - reports.iter().map(|report| repo_label(report, &context.root).chars().count()), + reports.iter().map(|report| repo_label(&report.path, &context.root).chars().count()), TABLE_HEADERS[0], ); 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], ); @@ -815,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?}", @@ -918,36 +932,42 @@ fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { mod tests { use super::*; - fn worktree_report(branch: Option<&str>) -> RepoReport { - let changes = - WorkingTreeChanges { files: Vec::new(), nested_repos: Vec::new(), last_change: None }; - RepoReport { - path: PathBuf::from("/dev/claude-wt"), - changes, - unpushed_branches: Vec::new(), - worktree: Some(Worktree { - main_repo: PathBuf::from("/dev/claude"), - branch: branch.map(str::to_string), - }), - } + fn no_changes() -> WorkingTreeChanges { + WorkingTreeChanges { files: Vec::new(), nested_repos: Vec::new(), last_change: None } } - #[test] - fn gives_each_checked_out_branch_to_its_worktree() { - let worktree = BranchScope::CheckedOut(Some("feat".to_string())); - assert!(worktree.includes("feat")); - assert!(!worktree.includes("main")); - assert!(!BranchScope::CheckedOut(None).includes("main")); - let main_repo = BranchScope::AllBut(vec!["feat".to_string()]); - assert!(main_repo.includes("main")); - assert!(!main_repo.includes("feat")); + 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 labels_a_worktree_after_its_repo_and_branch() { - let root = Path::new("/dev"); - assert_eq!(repo_label(&worktree_report(Some("OW-701")), root), "claude [OW-701]"); - assert_eq!(repo_label(&worktree_report(None), root), "claude [claude-wt]"); + 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] From 896ef8b41d39b59a87e37fd0b83ccca7b7f8da83 Mon Sep 17 00:00:00 2001 From: Hugo Heneault Date: Tue, 29 Sep 2026 20:25:32 +0200 Subject: [PATCH 4/4] Push the branch of a selected worktree only Selecting a worktree narrowed the commit to it but not the push: with no branch selected, p went on to push every unpushed branch of the repo. A selected worktree now narrows the push to the branch it has checked out, next to any selected branch. --- CHANGELOG.md | 2 +- README.md | 6 ++-- src/interactive.rs | 69 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e97f5..1bf6e75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). - 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 and committed. + a worktree can be selected, diffed, committed, and its branch pushed. ### Changed diff --git a/README.md b/README.md index 4a99717..18c5d2f 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,10 @@ 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. +* `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 diff --git a/src/interactive.rs b/src/interactive.rs index 09106d1..8315a02 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -467,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) { @@ -656,6 +644,29 @@ fn commit_scope(report: &RepoReport, selection: &Selection) -> CommitScope { 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()); @@ -939,6 +950,38 @@ mod tests { 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", "--"]);