git-standup-cli 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Larsen Cundric
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # git-standup-cli
2
+
3
+ > What did I do yesterday? See your recent git activity at a glance.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g git-standup-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```sh
14
+ # What did I do yesterday?
15
+ git standup
16
+
17
+ # What did Alice do?
18
+ git standup @alice
19
+
20
+ # Show the whole team's activity
21
+ git standup --team
22
+
23
+ # Last 7 days
24
+ git standup -d 7
25
+
26
+ # Scan all repos in parent directory
27
+ git standup --all-repos
28
+ ```
29
+
30
+ ### Example output
31
+
32
+ ```
33
+ git standup Alice since Friday, Mar 7 ─────────────
34
+
35
+ 09:42 AM Fix auth token refresh on expired sessions
36
+ src/auth.ts, src/middleware.ts
37
+ 10:15 AM Add rate limiting to API endpoints
38
+ src/api/routes.ts, src/api/middleware.ts, tests/api.test.ts
39
+ 02:33 PM Update README with new API docs
40
+ README.md
41
+
42
+ 3 commits total
43
+ ```
44
+
45
+ ### Team view
46
+
47
+ ```
48
+ git standup the team since Friday, Mar 7 ──────────
49
+
50
+ Alice:
51
+ 09:42 AM Fix auth token refresh on expired sessions
52
+ 10:15 AM Add rate limiting to API endpoints
53
+
54
+ Bob:
55
+ 11:30 AM Refactor database connection pool
56
+ 03:45 PM Add migration for user preferences table
57
+
58
+ 4 commits total
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - **Smart weekday detection**: On Monday, shows Friday's work (skips weekends)
64
+ - **Multi-repo scanning**: Use `--all-repos` to scan sibling directories
65
+ - **Team view**: `--team` shows everyone's commits grouped by author
66
+ - **Files touched**: Shows which files each commit modified
67
+ - **Flexible lookback**: `-d N` to look back N days
68
+ - **Auto-dedupe**: Collapses duplicate commit messages (e.g. commit + PR merge). Disable with `--no-dedupe`
69
+
70
+ ## License
71
+
72
+ MIT
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "git-standup-cli",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "What did I do yesterday? See your recent git activity at a glance.",
6
+ "bin": {
7
+ "git-standup": "./src/cli.js"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "README.md"
12
+ ],
13
+ "keywords": ["git", "standup", "activity", "cli", "log"],
14
+ "author": "Larsen Cundric",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/larsencundric/git-standup-cli.git"
19
+ },
20
+ "homepage": "https://github.com/larsencundric/git-standup-cli#readme",
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "dependencies": {
25
+ "chalk": "5.6.2",
26
+ "commander": "14.0.3"
27
+ }
28
+ }
package/src/cli.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readdirSync, statSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
+ import chalk from 'chalk';
6
+ import { program } from 'commander';
7
+ import { git, gitLines, header, fatal, isInsideGitRepo, getGitUser, formatTime } from './utils.js';
8
+
9
+ program
10
+ .name('git-standup')
11
+ .description('What did I (or someone else) do recently? Shows commits from the last working day')
12
+ .argument('[author]', 'author name or @username (default: you)')
13
+ .option('-d, --days <number>', 'look back N days instead of last working day')
14
+ .option('-t, --team', 'show commits from everyone')
15
+ .option('-a, --all-repos', 'scan sibling directories for git repos too')
16
+ .option('--no-dedupe', 'show duplicate commit messages (e.g. commit + PR merge)')
17
+ .addHelpText('after', `
18
+ Examples:
19
+ git standup Your commits from the last working day
20
+ git standup @alice Alice's commits
21
+ git standup --team Everyone's commits
22
+ git standup -d 7 Your commits from the last 7 days
23
+ git standup --all-repos Scan sibling git repos too
24
+ git standup --no-dedupe Show all commits including duplicates`)
25
+ .version('1.0.0')
26
+ .action(run);
27
+
28
+ program.parse();
29
+
30
+ function getLastWorkingDay() {
31
+ const now = new Date();
32
+ const day = now.getDay();
33
+ let daysBack;
34
+ if (day === 1) daysBack = 3;
35
+ else if (day === 0) daysBack = 2;
36
+ else if (day === 6) daysBack = 1;
37
+ else daysBack = 1;
38
+
39
+ const d = new Date(now);
40
+ d.setDate(d.getDate() - daysBack);
41
+ d.setHours(0, 0, 0, 0);
42
+ return d;
43
+ }
44
+
45
+ function toLocalISO(d) {
46
+ const off = d.getTimezoneOffset();
47
+ const local = new Date(d.getTime() - off * 60000);
48
+ return local.toISOString().slice(0, 19);
49
+ }
50
+
51
+ function getCommitsForRepo(repoPath, since, authorFilter, showAll) {
52
+ const args = [
53
+ 'log', '--all',
54
+ `--since=${toLocalISO(since)}`,
55
+ '--format=%an%x00%aI%x00%s%x00%H',
56
+ '--no-merges',
57
+ ];
58
+ if (!showAll && authorFilter) {
59
+ args.push(`--author=${authorFilter}`);
60
+ }
61
+ const lines = gitLines(args, { cwd: repoPath, allowFail: true });
62
+ return lines.map((line) => {
63
+ const [author, date, msg, hash] = line.split('\0');
64
+ return { author, date, msg, hash };
65
+ }).filter((c) => c.author);
66
+ }
67
+
68
+ function batchGetFiles(repoPath, hashes) {
69
+ if (hashes.length === 0) return {};
70
+ const out = git(
71
+ ['log', '--no-walk', '--format=%x00%H', '--name-only', ...hashes],
72
+ { cwd: repoPath, allowFail: true },
73
+ );
74
+ const map = {};
75
+ if (!out) return map;
76
+ for (const block of out.split('\0')) {
77
+ const lines = block.split('\n').filter(Boolean);
78
+ if (lines.length === 0) continue;
79
+ const hash = lines[0];
80
+ map[hash] = lines.slice(1);
81
+ }
82
+ return map;
83
+ }
84
+
85
+ function findSiblingRepos(currentDir) {
86
+ const parent = join(currentDir, '..');
87
+ const repos = [];
88
+ try {
89
+ const entries = readdirSync(parent);
90
+ for (const entry of entries) {
91
+ const full = join(parent, entry);
92
+ try {
93
+ if (statSync(full).isDirectory()) {
94
+ try { statSync(join(full, '.git')); repos.push(full); } catch {}
95
+ }
96
+ } catch {}
97
+ }
98
+ } catch {}
99
+ return repos;
100
+ }
101
+
102
+ function run(authorArg, opts) {
103
+ let since;
104
+ if (opts.days) {
105
+ const days = parseInt(opts.days, 10);
106
+ if (Number.isNaN(days) || days <= 0) {
107
+ fatal('--days must be a positive integer');
108
+ }
109
+ const d = new Date();
110
+ d.setDate(d.getDate() - days);
111
+ d.setHours(0, 0, 0, 0);
112
+ since = d;
113
+ } else {
114
+ since = getLastWorkingDay();
115
+ }
116
+
117
+ const inGitRepo = isInsideGitRepo();
118
+
119
+ let authorFilter = null;
120
+ if (!opts.team) {
121
+ if (authorArg) {
122
+ authorFilter = authorArg.replace(/^@/, '');
123
+ } else {
124
+ if (inGitRepo) {
125
+ authorFilter = getGitUser();
126
+ } else {
127
+ fatal('Not inside a git repository. Use --all-repos from a parent directory.');
128
+ }
129
+ }
130
+ }
131
+
132
+ let repos = [];
133
+ if (inGitRepo) {
134
+ repos.push(git(['rev-parse', '--show-toplevel']));
135
+ }
136
+
137
+ if (opts.allRepos || !inGitRepo) {
138
+ const cwd = process.cwd();
139
+ const siblings = findSiblingRepos(inGitRepo ? git(['rev-parse', '--show-toplevel']) : cwd);
140
+ for (const s of siblings) {
141
+ if (!repos.includes(s)) repos.push(s);
142
+ }
143
+ if (!inGitRepo) {
144
+ try {
145
+ for (const entry of readdirSync(cwd)) {
146
+ const full = join(cwd, entry);
147
+ try { statSync(join(full, '.git')); if (!repos.includes(full)) repos.push(full); } catch {}
148
+ }
149
+ } catch {}
150
+ }
151
+ }
152
+
153
+ if (repos.length === 0) fatal('No git repositories found');
154
+
155
+ const sinceStr = since.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
156
+ const who = opts.team ? 'the team' : (authorFilter || 'you');
157
+ header(`git standup ${chalk.white(who)} ${chalk.dim(`since ${sinceStr}`)}`);
158
+
159
+ let totalCommits = 0;
160
+
161
+ for (const repo of repos) {
162
+ const repoName = basename(repo);
163
+ let commits = getCommitsForRepo(repo, since, authorFilter, opts.team);
164
+ if (commits.length === 0) continue;
165
+
166
+ // Dedupe: collapse commits with identical author+message (e.g. commit + PR merge)
167
+ // Strips trailing PR numbers like "(#123)" before comparing
168
+ if (opts.dedupe !== false) {
169
+ const normalize = (msg) => msg.replace(/\s*\(#\d+\)\s*$/, '').trim();
170
+ const seen = new Set();
171
+ commits = commits.filter((c) => {
172
+ const key = `${c.author}\0${normalize(c.msg)}`;
173
+ if (seen.has(key)) return false;
174
+ seen.add(key);
175
+ return true;
176
+ });
177
+ }
178
+
179
+ if (commits.length === 0) continue;
180
+ totalCommits += commits.length;
181
+
182
+ const fileMap = batchGetFiles(repo, commits.map((c) => c.hash));
183
+
184
+ if (repos.length > 1) {
185
+ console.log(` ${chalk.bold.blue(repoName)}`);
186
+ }
187
+
188
+ if (opts.team) {
189
+ const byAuthor = {};
190
+ for (const c of commits) {
191
+ if (!byAuthor[c.author]) byAuthor[c.author] = [];
192
+ byAuthor[c.author].push(c);
193
+ }
194
+ for (const [author, authorCommits] of Object.entries(byAuthor)) {
195
+ console.log(` ${chalk.yellow(author)}:`);
196
+ for (const c of authorCommits) {
197
+ const time = formatTime(c.date);
198
+ const files = fileMap[c.hash] || [];
199
+ console.log(` ${chalk.dim(time)} ${c.msg}`);
200
+ if (files.length > 0 && files.length <= 5) {
201
+ console.log(` ${chalk.dim(' ' + files.join(', '))}`);
202
+ } else if (files.length > 5) {
203
+ console.log(` ${chalk.dim(' ' + files.slice(0, 4).join(', '))} ${chalk.dim(`+${files.length - 4} more`)}`);
204
+ }
205
+ }
206
+ console.log();
207
+ }
208
+ } else {
209
+ for (const c of commits) {
210
+ const time = formatTime(c.date);
211
+ const files = fileMap[c.hash] || [];
212
+ console.log(` ${chalk.dim(time)} ${c.msg}`);
213
+ if (files.length > 0 && files.length <= 5) {
214
+ console.log(` ${chalk.dim(' ' + files.join(', '))}`);
215
+ } else if (files.length > 5) {
216
+ console.log(` ${chalk.dim(' ' + files.slice(0, 4).join(', '))} ${chalk.dim(`+${files.length - 4} more`)}`);
217
+ }
218
+ }
219
+ console.log();
220
+ }
221
+ }
222
+
223
+ if (totalCommits === 0) {
224
+ console.log(chalk.dim(` No commits found since ${sinceStr}`));
225
+ console.log();
226
+ } else {
227
+ console.log(chalk.dim(` ${totalCommits} commit${totalCommits === 1 ? '' : 's'} total`));
228
+ console.log();
229
+ }
230
+ }
package/src/utils.js ADDED
@@ -0,0 +1,60 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import chalk from 'chalk';
3
+
4
+ export function git(args, opts = {}) {
5
+ try {
6
+ return execFileSync('git', args, {
7
+ encoding: 'utf-8',
8
+ maxBuffer: 50 * 1024 * 1024,
9
+ stdio: ['pipe', 'pipe', 'pipe'],
10
+ ...opts,
11
+ }).trim();
12
+ } catch (e) {
13
+ if (opts.allowFail) return '';
14
+ throw e;
15
+ }
16
+ }
17
+
18
+ export function gitLines(args, opts = {}) {
19
+ const out = git(args, opts);
20
+ return out ? out.split('\n') : [];
21
+ }
22
+
23
+ export function isInsideGitRepo() {
24
+ try {
25
+ git(['rev-parse', '--is-inside-work-tree']);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export function getGitUser() {
33
+ return git(['config', 'user.name'], { allowFail: true }) || 'unknown';
34
+ }
35
+
36
+ export const symbols = {
37
+ cross: '✗',
38
+ line: '─',
39
+ };
40
+
41
+ export function header(text) {
42
+ // Strip ANSI escape codes to get visible length for alignment
43
+ const visibleLength = text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').length;
44
+ const line = symbols.line.repeat(Math.max(0, 50 - visibleLength));
45
+ console.log(`\n${chalk.bold.cyan(text)} ${chalk.dim(line)}\n`);
46
+ }
47
+
48
+ export function fatal(msg) {
49
+ console.error(`${chalk.red(symbols.cross)} ${msg}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ export function formatTime(dateStr) {
54
+ const d = new Date(dateStr);
55
+ return d.toLocaleTimeString('en-US', {
56
+ hour: '2-digit',
57
+ minute: '2-digit',
58
+ hour12: true,
59
+ });
60
+ }