Skip to content

Commit d321bc8

Browse files
Merge pull request #6 from writingdeveloper/feat/git-insights-and-scan
Unpushed signal + nested-repo scan + open-in-editor (DECK-13/12/10)
2 parents 22133e3 + 80dc7cb commit d321bc8

21 files changed

Lines changed: 194 additions & 19 deletions

scripts/demo-fixture.mjs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const user = (text) => JSON.stringify({ type: 'user', message: { content: text }
3939
// stale days chosen for a balanced traffic-light spread (fresh<1, neutral<3, warn<7, else neglected).
4040
// usageDaysAgo clustered in the last week for a clean Usage chart (decoupled from deck staleness).
4141
const PROJECTS = [
42-
{ name: 'acme-dashboard', branch: 'main', stale: 0, dirty: 3,
42+
{ name: 'acme-dashboard', branch: 'main', stale: 0, dirty: 3, ahead: 2,
4343
commit: 'feat(ui): dark mode toggle + theme persistence',
4444
sessions: [{ first: 'scaffold the settings page', cue: 'add a dark mode toggle to the settings page', model: 'claude-opus-4-8', usageDaysAgo: [0, 1] }] },
4545
{ name: 'payments-api', branch: 'feat/refunds', stale: 0, dirty: 1,
@@ -76,6 +76,17 @@ for (const p of PROJECTS) {
7676
git(repo, ['add', '-A'], d);
7777
git(repo, ['commit', '-m', p.commit], d);
7878
if (p.branch !== 'main') git(repo, ['checkout', '-b', p.branch], d);
79+
// simulate unpushed commits: set an upstream ref at the current HEAD, then commit ahead of it
80+
if (p.ahead) {
81+
git(repo, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'], d); // sets remote.origin.fetch refspec
82+
git(repo, ['update-ref', `refs/remotes/origin/${p.branch}`, 'HEAD'], d);
83+
git(repo, ['branch', `--set-upstream-to=origin/${p.branch}`, p.branch], d);
84+
for (let i = 0; i < p.ahead; i++) {
85+
writeFileSync(join(repo, `local_${i}.js`), `// local work ${i}\n`);
86+
git(repo, ['add', '-A'], d);
87+
git(repo, ['commit', '-m', `feat: local work ${i}`], d);
88+
}
89+
}
7990
// leave N uncommitted changes
8091
for (let i = 0; i < p.dirty; i++) writeFileSync(join(repo, `wip_${i}.txt`), `work in progress ${i}\n`);
8192

@@ -94,5 +105,20 @@ for (const p of PROJECTS) {
94105
});
95106
}
96107

97-
console.log('projects:', PROJECTS.length, '| sessions:', sid);
108+
// nested org/repo (depth 2) to exercise nested scanning: acme-labs/ is a non-repo dir holding a repo
109+
const np = join(REPOS, 'acme-labs', 'prototype');
110+
const nd = iso(3);
111+
mkdirSync(np, { recursive: true });
112+
git(np, ['-c', 'init.defaultBranch=main', 'init'], nd);
113+
writeFileSync(join(np, 'README.md'), '# prototype\n');
114+
git(np, ['add', '-A'], nd);
115+
git(np, ['commit', '-m', 'init prototype scaffold'], nd);
116+
const nsd = join(CLAUDE, encode(np));
117+
mkdirSync(nsd, { recursive: true });
118+
const nf = join(nsd, uuid(sid++) + '.jsonl');
119+
writeFileSync(nf, [user('scaffold the prototype'), asst('claude-sonnet-4-6', iso(3, 11), 30000, 5000, 80000), user('wire up the auth flow')].join('\n'));
120+
const nms = NOW - 3 * DAY;
121+
utimesSync(nf, new Date(nms), new Date(nms));
122+
123+
console.log('projects:', PROJECTS.length + 1, '| sessions:', sid);
98124
console.log(HOME);

src/main/gitInfo.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getGitInfo } from './gitInfo';
55
function fakeRunner(map: Record<string, string>) {
66
return async (args: string[]): Promise<string> => {
77
if (args.includes('rev-parse')) return map.branch ?? '';
8+
if (args.includes('rev-list')) return map.ahead ?? '';
89
if (args.includes('log')) return map.log ?? '';
910
if (args.includes('status')) return map.status ?? '';
1011
return '';
@@ -17,15 +18,26 @@ describe('getGitInfo', () => {
1718
branch: 'main\n',
1819
log: '1717287840|scaffold\n',
1920
status: ' M a.ts\n?? b.ts\n',
21+
ahead: '2\n',
2022
});
2123
expect(await getGitInfo('C:\\g\\x', run)).toEqual({
2224
branch: 'main',
2325
lastCommitMs: 1717287840000,
2426
lastSubject: 'scaffold',
2527
uncommitted: 2,
28+
ahead: 2,
2629
});
2730
});
2831

32+
it('reports null ahead when there is no upstream (rev-list throws)', async () => {
33+
const run = async (args: string[]): Promise<string> => {
34+
if (args.includes('rev-list')) throw new Error('no upstream');
35+
if (args.includes('rev-parse')) return 'main\n';
36+
return '';
37+
};
38+
expect((await getGitInfo('C:\\g\\x', run)).ahead).toBeNull();
39+
});
40+
2941
it('handles a repo with no commits', async () => {
3042
const run = fakeRunner({ branch: 'main\n', log: '', status: '' });
3143
const info = await getGitInfo('C:\\g\\x', run);

src/main/gitInfo.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFile } from 'node:child_process';
22
import { promisify } from 'node:util';
33
import type { GitInfo } from '../shared/types';
4-
import { parseBranch, parseLastCommit, parsePorcelainCount } from '../shared/gitParse';
4+
import { parseBranch, parseLastCommit, parsePorcelainCount, parseAheadCount } from '../shared/gitParse';
55

66
const execFileAsync = promisify(execFile);
77

@@ -21,16 +21,18 @@ async function safe(run: GitRunner, args: string[]): Promise<string | null> {
2121
}
2222

2323
export async function getGitInfo(dir: string, run: GitRunner = defaultRunner): Promise<GitInfo> {
24-
const [branchOut, logOut, statusOut] = await Promise.all([
24+
const [branchOut, logOut, statusOut, aheadOut] = await Promise.all([
2525
safe(run, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD']),
2626
safe(run, ['-C', dir, 'log', '-1', '--format=%ct|%s']),
2727
safe(run, ['-C', dir, 'status', '--porcelain']),
28+
safe(run, ['-C', dir, 'rev-list', '--count', '@{upstream}..HEAD']),
2829
]);
2930
const { lastCommitMs, lastSubject } = parseLastCommit(logOut ?? '');
3031
return {
3132
branch: branchOut == null ? null : parseBranch(branchOut),
3233
lastCommitMs,
3334
lastSubject,
3435
uncommitted: parsePorcelainCount(statusOut ?? ''),
36+
ahead: aheadOut == null ? null : parseAheadCount(aheadOut),
3537
};
3638
}

src/main/ipc.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { scanRepos } from './scanner';
66
import { getGitInfo } from './gitInfo';
77
import { listSessions, isValidSessionId, lastUserMessageForSession } from './sessions';
88
import { buildProjectList } from './projects';
9-
import { openProjects } from './launcher';
9+
import { openProjects, openInEditor } from './launcher';
1010
import type { WtTab } from '../shared/wtArgs';
1111
import { scanUsage } from './usageScan';
1212
import { DEFAULT_THRESHOLDS } from '../shared/staleness';
@@ -121,6 +121,15 @@ export function registerIpc(cfg: IpcConfig): void {
121121
if (err) cfg.sendError(err);
122122
});
123123

124+
// Open the project in VS Code (`code <path>`).
125+
ipcMain.handle('project:openEditor', (_e, p: string) => {
126+
if (!isUnderBase(effBaseDir(), p)) {
127+
cfg.sendError(`Path outside base dir: ${p}`);
128+
return;
129+
}
130+
openInEditor(p, { onError: cfg.sendError });
131+
});
132+
124133
// Frameless-window controls (the title bar draws its own buttons).
125134
ipcMain.handle('win:minimize', () => cfg.win.minimize());
126135
ipcMain.handle('win:toggleMaximize', () => {

src/main/launcher.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { openProjects, resolveShell, resolveWtPath, detectLinuxTerminal } from './launcher';
2+
import { openProjects, resolveShell, resolveWtPath, detectLinuxTerminal, editorSpec, openInEditor } from './launcher';
33
import type { WtTab } from '../shared/wtArgs';
44

55
type Call = { file: string; args: string[] };
@@ -65,6 +65,24 @@ describe('detectLinuxTerminal', () => {
6565
});
6666
});
6767

68+
describe('editorSpec', () => {
69+
it('posix: code with the dir as an args-array entry (no shell)', () => {
70+
expect(editorSpec('/g/a', 'darwin')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
71+
expect(editorSpec('/g/a', 'linux')).toEqual({ command: 'code', args: ['/g/a'], shell: false });
72+
});
73+
it('windows: quoted path through a shell (code is code.cmd)', () => {
74+
expect(editorSpec('C:\\g\\a', 'win32')).toEqual({ command: 'code "C:\\g\\a"', args: [], shell: true });
75+
});
76+
});
77+
78+
describe('openInEditor', () => {
79+
it('dispatches the editor spec to spawnFn', () => {
80+
let got: { c: string; a: string[]; s: boolean } | null = null;
81+
openInEditor('/g/a', { platform: 'linux', spawnFn: (c, a, s) => { got = { c, a, s }; } });
82+
expect(got).toEqual({ c: 'code', a: ['/g/a'], s: false });
83+
});
84+
});
85+
6886
describe('resolveShell', () => {
6987
it('returns pwsh when present', () => { expect(resolveShell(() => true)).toBe('pwsh'); });
7088
it('falls back to powershell when absent', () => { expect(resolveShell(() => false)).toBe('powershell'); });

src/main/launcher.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ function defaultSpawn(onError?: (msg: string) => void): SpawnFn {
8888
};
8989
}
9090

91+
export interface EditorSpec { command: string; args: string[]; shell: boolean; }
92+
93+
/**
94+
* How to launch VS Code at a directory. On POSIX `code` is a plain script — pass the
95+
* dir as an args-array entry (no shell = injection-safe). On Windows `code` is
96+
* `code.cmd`, which Node can only run through a shell, so quote the path (Windows
97+
* paths cannot contain `"`, so the quoting is safe).
98+
*/
99+
export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform): EditorSpec {
100+
if (platform === 'win32') return { command: `code "${dir}"`, args: [], shell: true };
101+
return { command: 'code', args: [dir], shell: false };
102+
}
103+
104+
export interface EditorOptions {
105+
platform?: NodeJS.Platform;
106+
spawnFn?: (command: string, args: string[], shell: boolean) => void;
107+
onError?: (msg: string) => void;
108+
}
109+
110+
/** Open a directory in VS Code (`code <dir>`). */
111+
export function openInEditor(dir: string, opts: EditorOptions = {}): void {
112+
const spec = editorSpec(dir, opts.platform ?? process.platform);
113+
if (opts.spawnFn) { opts.spawnFn(spec.command, spec.args, spec.shell); return; }
114+
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false });
115+
child.on('error', (err) => {
116+
console.error('DevDeck: failed to open editor', err);
117+
opts.onError?.(`Failed to open editor (is the \`code\` CLI on your PATH?): ${(err as Error).message}`);
118+
});
119+
child.unref();
120+
}
121+
91122
/** Open each project in a platform-appropriate terminal running its command. */
92123
export function openProjects(tabs: WtTab[], opts: OpenOptions = {}): void {
93124
if (tabs.length === 0) return;

src/main/projects.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function deps(over: Partial<BuildDeps>): BuildDeps {
2020
lastCommitMs: dir.endsWith('fresh') ? NOW - 3_600_000 : NOW - 10 * DAY,
2121
lastSubject: 'x',
2222
uncommitted: 0,
23+
ahead: null,
2324
}),
2425
sessions: () => [],
2526
resumeCue: () => null,
@@ -59,7 +60,7 @@ describe('buildProjectList', () => {
5960
it('wires sessions + sessionCount and uses the latest session for activity', async () => {
6061
const NOW2 = NOW;
6162
const list = await buildProjectList(deps({
62-
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0 }),
63+
git: async (): Promise<GitInfo> => ({ branch: 'main', lastCommitMs: null, lastSubject: null, uncommitted: 0, ahead: 4 }),
6364
sessions: (p) => p.endsWith('fresh')
6465
? [{ id: 'x', mtimeMs: NOW2 - 3_600_000, firstMessage: 'hi' }]
6566
: [],
@@ -69,6 +70,7 @@ describe('buildProjectList', () => {
6970
expect(fresh.sessions[0].firstMessage).toBe('hi');
7071
expect(fresh.lastSessionMs).toBe(NOW2 - 3_600_000);
7172
expect(fresh.stale.level).toBe('fresh');
73+
expect(fresh.ahead).toBe(4); // unpushed count threaded through
7274
});
7375

7476
it('derives resumeCue from the newest session via the dep', async () => {

src/main/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export async function buildProjectList(deps: BuildDeps): Promise<ProjectViewMode
3434
name: r.name,
3535
branch: git.branch,
3636
uncommitted: git.uncommitted,
37+
ahead: git.ahead,
3738
lastCommitMs: git.lastCommitMs,
3839
lastSubject: git.lastSubject,
3940
lastSessionMs,

src/main/scanner.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,19 @@ describe('scanRepos', () => {
2424
const repos = await scanRepos(base);
2525
expect(repos[0].path.startsWith(base)).toBe(true);
2626
});
27+
28+
it('finds org/repo (depth 2) but not repos inside a repo or beyond maxDepth', async () => {
29+
mkdirSync(join(base, 'org', 'repoX', '.git'), { recursive: true }); // depth-2 repo under a non-repo
30+
mkdirSync(join(base, 'org', 'repoY', '.git'), { recursive: true });
31+
mkdirSync(join(base, 'projA', 'nested', '.git'), { recursive: true }); // inside a repo -> NOT scanned
32+
mkdirSync(join(base, 'deep', 'a', 'b', '.git'), { recursive: true }); // depth 3 -> NOT scanned
33+
const repos = (await scanRepos(base)).map((p) => p.name).sort();
34+
expect(repos).toEqual(['projA', 'projB', 'repoX', 'repoY']);
35+
});
36+
37+
it('respects an explicit maxDepth of 1 (top level only)', async () => {
38+
mkdirSync(join(base, 'org', 'repoX', '.git'), { recursive: true });
39+
const repos = (await scanRepos(base, 1)).map((p) => p.name).sort();
40+
expect(repos).toEqual(['projA', 'projB']);
41+
});
2742
});

src/main/scanner.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
import { readdir, access } from 'node:fs/promises';
22
import { join } from 'node:path';
33

4-
const IGNORE = new Set(['__pycache__', '.pytest_cache', '.claude', '.playwright-mcp']);
4+
const IGNORE = new Set(['__pycache__', '.pytest_cache', '.claude', '.playwright-mcp', 'node_modules']);
55

66
export interface RawProject {
77
path: string;
88
name: string;
99
}
1010

11-
export async function scanRepos(baseDir: string): Promise<RawProject[]> {
12-
const out: RawProject[] = [];
11+
async function isRepo(dir: string): Promise<boolean> {
12+
try {
13+
await access(join(dir, '.git'));
14+
return true;
15+
} catch {
16+
return false;
17+
}
18+
}
19+
20+
async function walk(dir: string, depth: number, maxDepth: number, out: RawProject[]): Promise<void> {
1321
let entries;
1422
try {
15-
entries = await readdir(baseDir, { withFileTypes: true });
23+
entries = await readdir(dir, { withFileTypes: true });
1624
} catch {
17-
return out;
25+
return;
1826
}
1927
await Promise.all(
2028
entries
2129
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !IGNORE.has(e.name))
2230
.map(async (entry) => {
23-
const full = join(baseDir, entry.name);
24-
try {
25-
await access(join(full, '.git'));
26-
out.push({ path: full, name: entry.name });
27-
} catch { /* no .git */ }
31+
const full = join(dir, entry.name);
32+
if (await isRepo(full)) {
33+
out.push({ path: full, name: entry.name }); // a repo — include it, don't descend into it
34+
} else if (depth < maxDepth) {
35+
await walk(full, depth + 1, maxDepth, out); // not a repo — look one level deeper (org/repo layouts)
36+
}
2837
}),
2938
);
39+
}
40+
41+
/** Find git repos under baseDir, scanning up to maxDepth levels (default 2 = org/repo). */
42+
export async function scanRepos(baseDir: string, maxDepth = 2): Promise<RawProject[]> {
43+
const out: RawProject[] = [];
44+
await walk(baseDir, 1, maxDepth, out);
3045
return out;
3146
}

0 commit comments

Comments
 (0)