@f0rbit/overview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +242 -0
  2. package/bunfig.toml +7 -0
  3. package/package.json +42 -0
  4. package/packages/core/__tests__/concurrency.test.ts +111 -0
  5. package/packages/core/__tests__/helpers.ts +60 -0
  6. package/packages/core/__tests__/integration/git-status.test.ts +62 -0
  7. package/packages/core/__tests__/integration/scanner.test.ts +140 -0
  8. package/packages/core/__tests__/ocn.test.ts +164 -0
  9. package/packages/core/package.json +13 -0
  10. package/packages/core/src/cache.ts +31 -0
  11. package/packages/core/src/concurrency.ts +44 -0
  12. package/packages/core/src/devpad.ts +61 -0
  13. package/packages/core/src/git-graph.ts +54 -0
  14. package/packages/core/src/git-stats.ts +201 -0
  15. package/packages/core/src/git-status.ts +316 -0
  16. package/packages/core/src/github.ts +286 -0
  17. package/packages/core/src/index.ts +58 -0
  18. package/packages/core/src/ocn.ts +74 -0
  19. package/packages/core/src/scanner.ts +118 -0
  20. package/packages/core/src/types.ts +199 -0
  21. package/packages/core/src/watcher.ts +128 -0
  22. package/packages/core/src/worktree.ts +80 -0
  23. package/packages/core/tsconfig.json +5 -0
  24. package/packages/render/bunfig.toml +8 -0
  25. package/packages/render/jsx-runtime.d.ts +3 -0
  26. package/packages/render/package.json +18 -0
  27. package/packages/render/src/components/__tests__/scrollbox-height.test.tsx +780 -0
  28. package/packages/render/src/components/__tests__/widget-container.integration.test.tsx +304 -0
  29. package/packages/render/src/components/git-graph.tsx +127 -0
  30. package/packages/render/src/components/help-overlay.tsx +108 -0
  31. package/packages/render/src/components/index.ts +7 -0
  32. package/packages/render/src/components/repo-list.tsx +127 -0
  33. package/packages/render/src/components/stats-panel.tsx +116 -0
  34. package/packages/render/src/components/status-badge.tsx +70 -0
  35. package/packages/render/src/components/status-bar.tsx +56 -0
  36. package/packages/render/src/components/widget-container.tsx +286 -0
  37. package/packages/render/src/components/widgets/__tests__/widget-rendering.test.tsx +326 -0
  38. package/packages/render/src/components/widgets/branch-list.tsx +93 -0
  39. package/packages/render/src/components/widgets/commit-activity.tsx +112 -0
  40. package/packages/render/src/components/widgets/devpad-milestones.tsx +88 -0
  41. package/packages/render/src/components/widgets/devpad-tasks.tsx +81 -0
  42. package/packages/render/src/components/widgets/file-changes.tsx +78 -0
  43. package/packages/render/src/components/widgets/git-status.tsx +125 -0
  44. package/packages/render/src/components/widgets/github-ci.tsx +98 -0
  45. package/packages/render/src/components/widgets/github-issues.tsx +101 -0
  46. package/packages/render/src/components/widgets/github-prs.tsx +119 -0
  47. package/packages/render/src/components/widgets/github-release.tsx +73 -0
  48. package/packages/render/src/components/widgets/index.ts +12 -0
  49. package/packages/render/src/components/widgets/recent-commits.tsx +64 -0
  50. package/packages/render/src/components/widgets/registry.ts +23 -0
  51. package/packages/render/src/components/widgets/repo-meta.tsx +80 -0
  52. package/packages/render/src/config/index.ts +104 -0
  53. package/packages/render/src/lib/__tests__/fetch-context.test.ts +200 -0
  54. package/packages/render/src/lib/__tests__/widget-grid.test.ts +665 -0
  55. package/packages/render/src/lib/actions.ts +68 -0
  56. package/packages/render/src/lib/fetch-context.ts +102 -0
  57. package/packages/render/src/lib/filter.ts +94 -0
  58. package/packages/render/src/lib/format.ts +36 -0
  59. package/packages/render/src/lib/use-devpad.ts +167 -0
  60. package/packages/render/src/lib/use-github.ts +75 -0
  61. package/packages/render/src/lib/widget-grid.ts +204 -0
  62. package/packages/render/src/lib/widget-state.ts +96 -0
  63. package/packages/render/src/overview.tsx +16 -0
  64. package/packages/render/src/screens/index.ts +1 -0
  65. package/packages/render/src/screens/main-screen.tsx +410 -0
  66. package/packages/render/src/theme/index.ts +37 -0
  67. package/packages/render/tsconfig.json +9 -0
  68. package/tsconfig.json +23 -0
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # overview
2
+
3
+ A terminal UI that scans a directory tree for git repositories, displays them in a hierarchical list with per-repo health indicators, and provides a split-pane detail view with an embedded git graph and widget-based stats panel.
4
+
5
+ ## Why use this
6
+
7
+ If you work across many git repositories, it is hard to know which ones have uncommitted changes, unpushed commits, or are behind their remote. `overview` gives you a single dashboard that answers "what is the state of all my repos?" at a glance, with the ability to drill into any repo for its full commit graph and metadata -- or launch directly into your editor, ggi, or a tmux session.
8
+
9
+ ## Interface
10
+
11
+ Three-panel split layout: a hierarchical repo list on the left, a git graph (top-right), and a scrollable widget panel (bottom-right). The repo list shows inline health badges -- selecting a repo loads its graph and widgets on demand.
12
+
13
+ ```
14
+ ┌─ overview ──────────────────── ~/dev ──── 28 repos ── scanning... ──┐
15
+ │ │
16
+ ├─────────────────────────┬───────────────────────────────────────────┤
17
+ │ ~/dev │ ┌─ git graph ───────────────────────────┐ │
18
+ │ ├── algorithms/ ✓ │ │ * 4a2f1c3 (HEAD -> main) fix: t... │ │
19
+ │ ├── bases.nvim/ ✓ │ │ * 8b3e2d1 feat: add treesitter... │ │
20
+ │ ├── burning-blends/ ✓ │ │ * c7f9a0e refactor: extract pa... │ │
21
+ │ ├── byron-kastelic/ ✓ │ │ | * 2d4e6f8 (origin/dev) wip:... │ │
22
+ │ ├── chamber/ ↑3 │ │ | |/ │ │
23
+ │ ├── corpus/ ↑1 │ │ * | a1b2c3d merge: dev into m... │ │
24
+ │ ├── cs-club-websit… ✓ │ │ |\ \ │ │
25
+ │ ├── database/ ✓ │ │ | * 5f6g7h8 fix: query perfor... │ │
26
+ │ ├── dev-blog-go/ ✓ │ │ * | 9i0j1k2 chore: bump deps │ │
27
+ │ ├── dev-blog/ ✓ │ │ |/ │ │
28
+ │ ├── devpad/ * ↑2 │ │ * l3m4n5o v2.1.0 release │ │
29
+ │ >├── dotfiles/ ~3 │ │ │ │
30
+ │ ├── forbit-astro/ ✓ │ └───────────────────────────────────────┘ │
31
+ │ ├── gallery/ ✓ │ ┌─ widgets: dotfiles ──────────────────┐ │
32
+ │ ├── gm-server/ ✓ │ │ │ │
33
+ │ ├── hackertui/ ↑1 │ │ branch main │ │
34
+ │ ├── key-grip/ ✓ │ │ remote origin (github.com/...) │ │
35
+ │ ├── media-timeline/ ✓ │ │ │ │
36
+ │ ├── mycelia/ ✓ │ │ ~ 3 modified + 0 staged │ │
37
+ │ ├── ocn/ ✓ │ │ ? 1 untracked ! 0 conflicts │ │
38
+ │ ├── rollette/ ✓ │ │ │ │
39
+ │ ├── runbook/ > ↑5 │ │ last commit 2h ago │ │
40
+ │ ├── studdy-buddy/ ✓ │ │ contributors 3 │ │
41
+ │ ├── todo-tracker/ ✓ │ │ branches 4 local / 6 remote │ │
42
+ │ └── ui/ ↑1 │ │ tags v1.0, v1.1, v2.0 │ │
43
+ │ │ │ │ │
44
+ │ │ └──────────────────────────────────────┘ │
45
+ ├─────────────────────────┴───────────────────────────────────────────┤
46
+ │ [NORMAL] j/k:nav Enter:detail g:ggi r:refresh q:quit ?:help │
47
+ └─────────────────────────────────────────────────────────────────────┘
48
+ ```
49
+
50
+ ### Status badges
51
+
52
+ | Badge | Meaning |
53
+ |-------|---------|
54
+ | `*` | OpenCode session active (busy) |
55
+ | `>` | OpenCode session needs input |
56
+ | `!` | OpenCode session errored / merge conflicts |
57
+ | `✓` | Clean -- nothing to commit, up to date |
58
+ | `↑3` | 3 commits ahead (unpushed) |
59
+ | `↓2` | 2 commits behind remote |
60
+ | `~3` | 3 uncommitted changes |
61
+ | `?` | Untracked files only |
62
+
63
+ ## Install and run
64
+
65
+ Requires [Bun](https://bun.sh) >= 1.0 and `git`. Optional: `fzf` + `delta` for [ggi](https://github.com/f0rbit/ggi) integration, `gh` CLI for GitHub widgets.
66
+
67
+ ```sh
68
+ # run without installing
69
+ bunx @f0rbit/overview
70
+
71
+ # or install globally
72
+ bun add -g @f0rbit/overview
73
+ overview
74
+ ```
75
+
76
+ CLI flags override config values:
77
+
78
+ ```sh
79
+ overview --dir ~/workplace --depth 2 --sort status --filter dirty
80
+ ```
81
+
82
+ ### Development
83
+
84
+ ```sh
85
+ git clone https://github.com/f0rbit/overview.git
86
+ cd overview
87
+ bun install
88
+
89
+ bun run dev # run in dev mode
90
+ bun run build # compile standalone binary
91
+ ./overview
92
+ ```
93
+
94
+ ### Configuration
95
+
96
+ Config lives at `~/.config/overview/config.toml`. A default is created on first run. Key options:
97
+
98
+ ```toml
99
+ scan_dirs = ["~/dev"]
100
+ depth = 3
101
+ refresh_interval = 30
102
+ sort = "name" # name | status | last-commit
103
+ filter = "all" # all | dirty | clean | ahead | behind
104
+
105
+ [layout]
106
+ left_width_pct = 35
107
+ graph_height_pct = 45
108
+
109
+ [actions]
110
+ ggi = "ggi"
111
+ editor = "$EDITOR"
112
+ sessionizer = "" # path to tmux-sessionizer script
113
+ ```
114
+
115
+ ## Keybindings
116
+
117
+ ### Normal mode
118
+
119
+ | Key | Action |
120
+ |-----|--------|
121
+ | `j` / `k` | Navigate repo list |
122
+ | `Enter` / `l` | Enter detail mode (focus right panels) |
123
+ | `Tab` | Cycle focus: list / graph / stats |
124
+ | `f` | Cycle filter: all / dirty / clean / ahead / behind |
125
+ | `s` | Cycle sort: name / status / last-commit |
126
+ | `r` | Refresh selected repo |
127
+ | `R` | Full rescan (re-walk directory tree) |
128
+ | `g` | Launch ggi in selected repo |
129
+ | `o` | Open selected repo in `$EDITOR` |
130
+ | `t` | Open tmux session for selected repo |
131
+ | `?` | Toggle help overlay |
132
+ | `q` / `Esc` | Quit |
133
+
134
+ ### Detail mode
135
+
136
+ | Key | Action |
137
+ |-----|--------|
138
+ | `j` / `k` | Scroll focused panel / navigate widgets in stats |
139
+ | `h` | Move left (graph, or exit to list if on graph) |
140
+ | `l` | Move right (stats) |
141
+ | `g` | Launch ggi |
142
+ | `o` | Open in `$EDITOR` |
143
+ | `t` | Open tmux session |
144
+ | `r` | Refresh details |
145
+ | `q` / `Esc` | Back to normal mode |
146
+
147
+ ## Widget system
148
+
149
+ The stats panel is composed of configurable widgets laid out in a responsive grid. Widgets can span the full panel width, half, or a third. Enable/disable and reorder widgets via `~/.config/overview/widgets.json`.
150
+
151
+ | Widget | Span | Description |
152
+ |--------|------|-------------|
153
+ | Git Status | third | Working tree status, staged/modified/untracked counts |
154
+ | Repo Meta | third | Commits, contributors, repo size, latest tag |
155
+ | GitHub CI | third | Recent workflow runs and their status |
156
+ | Commit Activity | third | 14-day commit sparkline |
157
+ | Latest Release | third | Latest GitHub release and commits since |
158
+ | Devpad Milestones | half | Project milestones from devpad |
159
+ | GitHub PRs | half | Open pull requests |
160
+ | GitHub Issues | half | Open issues |
161
+ | File Changes | half | Modified/staged/untracked file list |
162
+ | Branches | half | Local and remote branches |
163
+ | Devpad Tasks | full | Task list from devpad |
164
+
165
+ ## OpenCode integration
166
+
167
+ Overview reads [OpenCode](https://github.com/sst/opencode) session state files from `~/.local/state/ocn/` and shows per-repo status indicators in the repo list (`*` busy, `>` needs input, `!` errored). This requires the [ocn](https://github.com/f0rbit/ocn) OpenCode plugin to be installed. Overview works fine without it -- the badges simply won't appear.
168
+
169
+ ## Architecture
170
+
171
+ Bun workspace monorepo with two packages:
172
+
173
+ ```
174
+ overview/
175
+ ├── packages/
176
+ │ ├── core/ # git operations, scanning, file watching
177
+ │ │ └── src/
178
+ │ │ ├── scanner.ts # directory tree walker, repo discovery
179
+ │ │ ├── worktree.ts # worktree detection and grouping
180
+ │ │ ├── git-status.ts # parallel status collection via Bun.spawn
181
+ │ │ ├── git-graph.ts # git log --graph capture (ANSI passthrough)
182
+ │ │ ├── git-stats.ts # heavyweight stats (on-demand)
183
+ │ │ ├── concurrency.ts # subprocess pool (capped at 8)
184
+ │ │ ├── ocn.ts # OpenCode session status reader
185
+ │ │ ├── watcher.ts # fs.watch on .git dirs for live updates
186
+ │ │ └── types.ts # core data model
187
+ │ └── render/ # TUI components, screens, theming
188
+ │ └── src/
189
+ │ ├── overview.tsx # entry point
190
+ │ ├── screens/
191
+ │ ├── components/ # repo list, git graph, widgets
192
+ │ ├── lib/
193
+ │ │ ├── widget-grid.ts # grid layout computation
194
+ │ │ ├── widget-state.ts # widget config persistence
195
+ │ │ ├── fetch-context.ts # request deduplication
196
+ │ │ └── filter.ts # repo filtering/sorting
197
+ │ ├── config/
198
+ │ └── theme/ # Tokyo Night color scheme
199
+ ├── package.json
200
+ └── tsconfig.json
201
+ ```
202
+
203
+ Data flows one direction: `core` scans and collects git data, `render` subscribes to it via SolidJS signals. The git graph panel renders `git log --graph --color=always` output directly -- no DAG parsing. Press `g` to launch ggi for interactive graph exploration (suspends the TUI, resumes on exit).
204
+
205
+ ## Performance
206
+
207
+ - Debounced repo selection (250ms) with request cancellation
208
+ - Capped subprocess concurrency (pool of 8)
209
+ - Deduplicated GitHub and Devpad API calls via in-flight tracking
210
+ - Memoized computations to avoid redundant re-renders
211
+
212
+ ## Dependencies
213
+
214
+ ### Runtime
215
+
216
+ | Dependency | Purpose |
217
+ |------------|---------|
218
+ | [Bun](https://bun.sh) | Runtime, bundler, test runner |
219
+ | [@opentui/solid](https://github.com/anomalyco/opentui) | TUI rendering framework ([docs](https://opentui.com)) |
220
+ | [SolidJS](https://www.solidjs.com/) | Reactive UI primitives |
221
+ | [@f0rbit/corpus](https://github.com/f0rbit/corpus) | `Result<T, E>` error handling -- no thrown exceptions |
222
+ | [@devpad/api](https://github.com/f0rbit/devpad) | Devpad project/task management client |
223
+
224
+ ### System (optional)
225
+
226
+ | Tool | Purpose |
227
+ |------|---------|
228
+ | `git` | Core data source (required) |
229
+ | `gh` | GitHub CLI for PR, issue, CI, and release widgets |
230
+ | [`fzf`](https://github.com/junegunn/fzf) | Interactive filtering for ggi integration |
231
+ | [`delta`](https://github.com/dandavison/delta) | Diff rendering for ggi integration |
232
+
233
+ ### Dev
234
+
235
+ | Dependency | Purpose |
236
+ |------------|---------|
237
+ | TypeScript | Type checking |
238
+ | [Biome](https://biomejs.dev/) | Linting and formatting |
239
+
240
+ ## License
241
+
242
+ MIT
package/bunfig.toml ADDED
@@ -0,0 +1,7 @@
1
+ preload = ["@opentui/solid/preload"]
2
+
3
+ [compilerOptions]
4
+ jsxImportSource = "solid-js"
5
+ jsx = "preserve"
6
+
7
+ [test]
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@f0rbit/overview",
3
+ "version": "0.1.0",
4
+ "description": "Terminal UI dashboard for multi-repo git health",
5
+ "workspaces": ["packages/*"],
6
+ "bin": {
7
+ "overview": "packages/render/src/overview.tsx"
8
+ },
9
+ "files": [
10
+ "packages/",
11
+ "bunfig.toml",
12
+ "tsconfig.json"
13
+ ],
14
+ "scripts": {
15
+ "dev": "bun run --filter @overview/render dev",
16
+ "typecheck": "bun run --filter '*' typecheck",
17
+ "lint": "biome check .",
18
+ "test": "bun test",
19
+ "build": "bun build packages/render/src/overview.tsx --compile --outfile overview"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/f0rbit/overview.git"
24
+ },
25
+ "license": "MIT",
26
+ "keywords": ["git", "tui", "terminal", "dashboard", "bun", "solidjs"],
27
+ "engines": {
28
+ "bun": ">=1.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.7.0",
32
+ "@types/bun": "latest",
33
+ "@biomejs/biome": "^1.9.0"
34
+ },
35
+ "dependencies": {
36
+ "@devpad/api": "^2.0.4",
37
+ "@f0rbit/corpus": "^0.3.5",
38
+ "@opentui/core": "^0.1.80",
39
+ "@opentui/solid": "^0.1.80",
40
+ "solid-js": "^1.9.11"
41
+ }
42
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createPool } from "../src/concurrency";
3
+
4
+ describe("createPool", () => {
5
+ test("respects concurrency limit", async () => {
6
+ const pool = createPool(2);
7
+ let max_concurrent = 0;
8
+ let current = 0;
9
+
10
+ const task = async () => {
11
+ current++;
12
+ max_concurrent = Math.max(max_concurrent, current);
13
+ await Bun.sleep(30);
14
+ current--;
15
+ };
16
+
17
+ await Promise.all([
18
+ pool.run(task),
19
+ pool.run(task),
20
+ pool.run(task),
21
+ pool.run(task),
22
+ pool.run(task),
23
+ ]);
24
+
25
+ expect(max_concurrent).toBe(2);
26
+ expect(current).toBe(0);
27
+ });
28
+
29
+ test("all tasks complete", async () => {
30
+ const pool = createPool(3);
31
+ const results: number[] = [];
32
+
33
+ const tasks = Array.from({ length: 10 }, (_, i) =>
34
+ pool.run(async () => {
35
+ await Bun.sleep(10);
36
+ results.push(i);
37
+ return i;
38
+ }),
39
+ );
40
+
41
+ const returned = await Promise.all(tasks);
42
+
43
+ expect(returned).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
44
+ expect(results).toHaveLength(10);
45
+ });
46
+
47
+ test("errors propagate correctly", async () => {
48
+ const pool = createPool(2);
49
+
50
+ const failing = pool.run(async () => {
51
+ throw new Error("boom");
52
+ });
53
+
54
+ await expect(failing).rejects.toThrow("boom");
55
+
56
+ // Pool should still work after error
57
+ const result = await pool.run(async () => "ok");
58
+ expect(result).toBe("ok");
59
+ });
60
+
61
+ test("queued tasks run as active tasks complete", async () => {
62
+ const pool = createPool(1);
63
+ const order: string[] = [];
64
+
65
+ const p1 = pool.run(async () => {
66
+ await Bun.sleep(30);
67
+ order.push("first");
68
+ });
69
+
70
+ const p2 = pool.run(async () => {
71
+ order.push("second");
72
+ });
73
+
74
+ await Promise.all([p1, p2]);
75
+ expect(order).toEqual(["first", "second"]);
76
+ });
77
+
78
+ test("active_count and queue_length track state", async () => {
79
+ const pool = createPool(2);
80
+ const started: Array<() => void> = [];
81
+
82
+ // Create tasks that block until we release them
83
+ const make_blocking = () =>
84
+ pool.run(() => new Promise<void>((resolve) => { started.push(resolve); }));
85
+
86
+ const p1 = make_blocking();
87
+ const p2 = make_blocking();
88
+ const p3 = make_blocking();
89
+
90
+ // Wait for first two to start
91
+ await Bun.sleep(10);
92
+
93
+ expect(pool.active_count).toBe(2);
94
+ expect(pool.queue_length).toBe(1);
95
+
96
+ // Release first task
97
+ started[0]!();
98
+ await Bun.sleep(10);
99
+
100
+ expect(pool.active_count).toBe(2); // third task started
101
+ expect(pool.queue_length).toBe(0);
102
+
103
+ // Release remaining
104
+ started[1]!();
105
+ started[2]!();
106
+ await Promise.all([p1, p2, p3]);
107
+
108
+ expect(pool.active_count).toBe(0);
109
+ expect(pool.queue_length).toBe(0);
110
+ });
111
+ });
@@ -0,0 +1,60 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ async function run(args: string[], cwd: string): Promise<void> {
6
+ const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" });
7
+ const code = await proc.exited;
8
+ if (code !== 0) {
9
+ const stderr = await new Response(proc.stderr).text();
10
+ throw new Error(`Command failed: ${args.join(" ")}\n${stderr}`);
11
+ }
12
+ }
13
+
14
+ export async function createTempDir(): Promise<string> {
15
+ return mkdtemp(join(tmpdir(), "overview-test-"));
16
+ }
17
+
18
+ export async function cleanupTempDir(dir: string): Promise<void> {
19
+ await rm(dir, { recursive: true, force: true });
20
+ }
21
+
22
+ export async function initRepo(dir: string, name: string): Promise<string> {
23
+ const repo_path = join(dir, name);
24
+ await Bun.spawn(["mkdir", "-p", repo_path]).exited;
25
+ await run(["git", "init"], repo_path);
26
+ await run(["git", "config", "user.email", "test@test.com"], repo_path);
27
+ await run(["git", "config", "user.name", "Test User"], repo_path);
28
+ await Bun.write(join(repo_path, "README.md"), "# " + name);
29
+ await run(["git", "add", "."], repo_path);
30
+ await run(["git", "commit", "-m", "initial commit"], repo_path);
31
+ return repo_path;
32
+ }
33
+
34
+ export async function addCommit(
35
+ repoPath: string,
36
+ filename: string,
37
+ content: string,
38
+ message: string,
39
+ ): Promise<void> {
40
+ await Bun.write(join(repoPath, filename), content);
41
+ await run(["git", "add", filename], repoPath);
42
+ await run(["git", "commit", "-m", message], repoPath);
43
+ }
44
+
45
+ export async function createBranch(repoPath: string, branchName: string): Promise<void> {
46
+ await run(["git", "checkout", "-b", branchName], repoPath);
47
+ }
48
+
49
+ export async function modifyFile(repoPath: string, filename: string, content: string): Promise<void> {
50
+ await Bun.write(join(repoPath, filename), content);
51
+ }
52
+
53
+ export async function addUntracked(repoPath: string, filename: string, content: string): Promise<void> {
54
+ await Bun.write(join(repoPath, filename), content);
55
+ }
56
+
57
+ export async function stashChanges(repoPath: string): Promise<void> {
58
+ await Bun.write(join(repoPath, "README.md"), "stash content");
59
+ await run(["git", "stash"], repoPath);
60
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { collectStatus } from "../../src/git-status";
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initRepo,
9
+ stashChanges,
10
+ } from "../helpers";
11
+
12
+ describe("git-status integration", () => {
13
+ let temp_dir: string;
14
+
15
+ beforeAll(async () => {
16
+ temp_dir = await createTempDir();
17
+ });
18
+
19
+ afterAll(async () => {
20
+ await cleanupTempDir(temp_dir);
21
+ });
22
+
23
+ test("parses branch info", async () => {
24
+ const dir = join(temp_dir, "branch-test");
25
+ await mkdir(dir, { recursive: true });
26
+ const repo_path = await initRepo(dir, "branch-repo");
27
+
28
+ const result = await collectStatus(repo_path, dir);
29
+ expect(result.ok).toBe(true);
30
+ if (!result.ok) return;
31
+
32
+ // git init may default to "main" or "master"
33
+ expect(["main", "master"]).toContain(result.value.current_branch);
34
+ });
35
+
36
+ test("detects stashes", async () => {
37
+ const dir = join(temp_dir, "stash-test");
38
+ await mkdir(dir, { recursive: true });
39
+ const repo_path = await initRepo(dir, "stash-repo");
40
+ await stashChanges(repo_path);
41
+
42
+ const result = await collectStatus(repo_path, dir);
43
+ expect(result.ok).toBe(true);
44
+ if (!result.ok) return;
45
+
46
+ expect(result.value.stash_count).toBeGreaterThan(0);
47
+ });
48
+
49
+ test("handles repo with no remote", async () => {
50
+ const dir = join(temp_dir, "no-remote-test");
51
+ await mkdir(dir, { recursive: true });
52
+ const repo_path = await initRepo(dir, "no-remote-repo");
53
+
54
+ const result = await collectStatus(repo_path, dir);
55
+ expect(result.ok).toBe(true);
56
+ if (!result.ok) return;
57
+
58
+ expect(result.value.remote_url).toBeNull();
59
+ expect(result.value.ahead).toBe(0);
60
+ expect(result.value.behind).toBe(0);
61
+ });
62
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { scanDirectory, scanAndCollect } from "../../src/index";
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ initRepo,
9
+ addCommit,
10
+ modifyFile,
11
+ addUntracked,
12
+ } from "../helpers";
13
+
14
+ describe("scanner integration", () => {
15
+ let temp_dir: string;
16
+
17
+ beforeAll(async () => {
18
+ temp_dir = await createTempDir();
19
+ });
20
+
21
+ afterAll(async () => {
22
+ await cleanupTempDir(temp_dir);
23
+ });
24
+
25
+ test("discovers repos at root level", async () => {
26
+ const dir = join(temp_dir, "root-level");
27
+ await mkdir(dir, { recursive: true });
28
+ await initRepo(dir, "repo-a");
29
+ await initRepo(dir, "repo-b");
30
+ await initRepo(dir, "repo-c");
31
+
32
+ const result = await scanDirectory(dir, { depth: 1, ignore: [] });
33
+ expect(result.ok).toBe(true);
34
+ if (!result.ok) return;
35
+
36
+ const repos = result.value.filter((n) => n.type === "repo");
37
+ expect(repos.length).toBe(3);
38
+ expect(repos.map((r) => r.name).sort()).toEqual(["repo-a", "repo-b", "repo-c"]);
39
+ });
40
+
41
+ test("discovers nested repos", async () => {
42
+ const dir = join(temp_dir, "nested");
43
+ await mkdir(dir, { recursive: true });
44
+ const nested_path = join(dir, "a", "b");
45
+ await mkdir(nested_path, { recursive: true });
46
+ await initRepo(nested_path, "repo1");
47
+
48
+ const result = await scanDirectory(dir, { depth: 4, ignore: [] });
49
+ expect(result.ok).toBe(true);
50
+ if (!result.ok) return;
51
+
52
+ const find_repo = (nodes: typeof result.value): boolean =>
53
+ nodes.some((n) => (n.type === "repo" && n.name === "repo1") || find_repo(n.children));
54
+
55
+ expect(find_repo(result.value)).toBe(true);
56
+ });
57
+
58
+ test("ignores directories matching ignore patterns", async () => {
59
+ const dir = join(temp_dir, "ignore-test");
60
+ await mkdir(dir, { recursive: true });
61
+ await initRepo(dir, "good-repo");
62
+ const nm_dir = join(dir, "node_modules");
63
+ await mkdir(nm_dir, { recursive: true });
64
+ await initRepo(nm_dir, "hidden-repo");
65
+
66
+ const result = await scanDirectory(dir, { depth: 2, ignore: ["node_modules"] });
67
+ expect(result.ok).toBe(true);
68
+ if (!result.ok) return;
69
+
70
+ const all_names = flatNames(result.value);
71
+ expect(all_names).toContain("good-repo");
72
+ expect(all_names).not.toContain("node_modules");
73
+ expect(all_names).not.toContain("hidden-repo");
74
+ });
75
+
76
+ test("collects status for clean repo", async () => {
77
+ const dir = join(temp_dir, "clean-test");
78
+ await mkdir(dir, { recursive: true });
79
+ await initRepo(dir, "clean-repo");
80
+ await addCommit(join(dir, "clean-repo"), "file.txt", "content", "add file");
81
+
82
+ const result = await scanAndCollect(dir, { depth: 1, ignore: [] });
83
+ expect(result.ok).toBe(true);
84
+ if (!result.ok) return;
85
+
86
+ const repo = result.value.find((n) => n.name === "clean-repo");
87
+ expect(repo).toBeDefined();
88
+ expect(repo!.status).not.toBeNull();
89
+ expect(repo!.status!.is_clean).toBe(true);
90
+ expect(repo!.status!.health).toBe("clean");
91
+ });
92
+
93
+ test("detects dirty state", async () => {
94
+ const dir = join(temp_dir, "dirty-test");
95
+ await mkdir(dir, { recursive: true });
96
+ const repo_path = await initRepo(dir, "dirty-repo");
97
+ await addCommit(repo_path, "file.txt", "original", "add file");
98
+ await modifyFile(repo_path, "file.txt", "modified content");
99
+
100
+ const result = await scanAndCollect(dir, { depth: 1, ignore: [] });
101
+ expect(result.ok).toBe(true);
102
+ if (!result.ok) return;
103
+
104
+ const repo = result.value.find((n) => n.name === "dirty-repo");
105
+ expect(repo).toBeDefined();
106
+ expect(repo!.status).not.toBeNull();
107
+ expect(repo!.status!.modified_count).toBeGreaterThan(0);
108
+ expect(repo!.status!.health).toBe("dirty");
109
+ });
110
+
111
+ test("detects untracked files", async () => {
112
+ const dir = join(temp_dir, "untracked-test");
113
+ await mkdir(dir, { recursive: true });
114
+ const repo_path = await initRepo(dir, "untracked-repo");
115
+ await addUntracked(repo_path, "new-file.txt", "untracked content");
116
+
117
+ const result = await scanAndCollect(dir, { depth: 1, ignore: [] });
118
+ expect(result.ok).toBe(true);
119
+ if (!result.ok) return;
120
+
121
+ const repo = result.value.find((n) => n.name === "untracked-repo");
122
+ expect(repo).toBeDefined();
123
+ expect(repo!.status).not.toBeNull();
124
+ expect(repo!.status!.untracked_count).toBeGreaterThan(0);
125
+ });
126
+
127
+ test("returns empty array for empty directory", async () => {
128
+ const dir = join(temp_dir, "empty-test");
129
+ await mkdir(dir, { recursive: true });
130
+
131
+ const result = await scanDirectory(dir, { depth: 1, ignore: [] });
132
+ expect(result.ok).toBe(true);
133
+ if (!result.ok) return;
134
+ expect(result.value).toEqual([]);
135
+ });
136
+ });
137
+
138
+ function flatNames(nodes: { name: string; children: typeof nodes }[]): string[] {
139
+ return nodes.flatMap((n) => [n.name, ...flatNames(n.children)]);
140
+ }