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 +21 -0
- package/README.md +72 -0
- package/package.json +28 -0
- package/src/cli.js +230 -0
- package/src/utils.js +60 -0
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
|
+
}
|