@bstopp/git-who 0.2.0 → 0.3.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/README.md +116 -0
- package/dist/package.json +3 -2
- package/dist/src/cli.js +18 -0
- package/dist/src/commands/check.js +41 -0
- package/dist/src/commands/hook.js +63 -0
- package/dist/src/commands/show.js +2 -80
- package/dist/src/utils/repo-profile.js +82 -0
- package/package.json +3 -2
- package/skills/git-who/SKILL.md +91 -0
package/README.md
CHANGED
|
@@ -70,12 +70,17 @@ exist, you get a warning and the same prompt.
|
|
|
70
70
|
| `git who list` | List all profiles with their SSH hosts. The profile active in the current repo is in bold. |
|
|
71
71
|
| `git who show [name]` | Show a profile. With no name, show the profile for the current repo. |
|
|
72
72
|
| `git who use [name]` | Apply a profile to the current repo. |
|
|
73
|
+
| `git who check` | Fail if the current repo's git config doesn't match its profile. |
|
|
74
|
+
| `git who hook install` | Run `check` before every commit in the current repo. See [Pre-commit hook](#pre-commit-hook). |
|
|
75
|
+
| `git who hook uninstall` | Stop running `check` before commits in the current repo. |
|
|
73
76
|
| `git who clone <repo> [name]` | Clone a repo using a profile, then apply the profile to the clone. |
|
|
74
77
|
| `git who edit [name]` | Edit a profile. The prompts are pre-filled with the current values. |
|
|
75
78
|
| `git who remove [name]` | Remove a profile, after confirmation. |
|
|
76
79
|
|
|
77
80
|
Run `git who --help` for usage and `git who --version` for the version.
|
|
78
81
|
|
|
82
|
+
`list`, `show`, `use` and `check` also take `--json`, for scripts and AI agents. See [JSON output](#json-output).
|
|
83
|
+
|
|
79
84
|
### `add`
|
|
80
85
|
|
|
81
86
|
Prompts for, in order:
|
|
@@ -88,6 +93,45 @@ Prompts for, in order:
|
|
|
88
93
|
directly. The identity's name and email become the defaults for the next two prompts.
|
|
89
94
|
4. **Git `user.name`** and **`user.email`**.
|
|
90
95
|
|
|
96
|
+
### `check`
|
|
97
|
+
|
|
98
|
+
Checks the current repo's git config against its profile (the one named by `gitwho.profile`, or the one matching
|
|
99
|
+
`user.name` and `user.email`) and exits non-zero if they differ. It compares `user.name`, `user.email`, `user.signingKey`
|
|
100
|
+
and whether commits are signed, and the error names each setting that differs. It prints nothing when the repo matches,
|
|
101
|
+
and never prompts.
|
|
102
|
+
|
|
103
|
+
To stop a commit made under the wrong identity, run it before every commit with a
|
|
104
|
+
[pre-commit hook](#pre-commit-hook). A repo with no profile, or whose identity matches none, fails the check too.
|
|
105
|
+
|
|
106
|
+
### Pre-commit hook
|
|
107
|
+
|
|
108
|
+
`git who hook install` makes git run `git who check` before every commit in the current repo, and a commit under the
|
|
109
|
+
wrong identity is refused with the settings that differ. `git who hook uninstall` takes it out again. Both only touch the
|
|
110
|
+
repo's local git config.
|
|
111
|
+
|
|
112
|
+
It uses git's config hooks (`hook.git-who.command` and `hook.git-who.event`), not a file in `.git/hooks`, so it runs
|
|
113
|
+
alongside any `pre-commit` hook the repo already has and never overwrites it. It needs a git recent enough to have them.
|
|
114
|
+
After installing, `git who` checks that `git hook list pre-commit` shows the hook, and if it doesn't it removes the
|
|
115
|
+
config again and tells you, rather than leaving a hook that never runs.
|
|
116
|
+
|
|
117
|
+
To do the same by hand:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
git config --local hook.git-who.command 'git who check'
|
|
121
|
+
git config --local hook.git-who.event pre-commit
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If you use [husky](https://typicode.github.io/husky), the hook above works with it as is. Or put the command in husky's
|
|
125
|
+
own hook instead, by adding this line to `.husky/pre-commit`:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
git who check
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`git commit --no-verify` skips the hook, because git skips `pre-commit` hooks with it. The hook is a guard against
|
|
132
|
+
forgetting to switch profiles, not something that enforces it. It also fails the commit if `git who` isn't on the
|
|
133
|
+
`PATH`, such as after uninstalling the tool without running `git who hook uninstall`.
|
|
134
|
+
|
|
91
135
|
### `use`
|
|
92
136
|
|
|
93
137
|
Applies a profile to the current repo by writing to its **local** git config:
|
|
@@ -167,6 +211,78 @@ Shows the profile and asks for confirmation. If the profile is the active one in
|
|
|
167
211
|
time and its `gitwho.profile` is cleared from that repo afterwards. Other repos that still name the removed profile are
|
|
168
212
|
not touched.
|
|
169
213
|
|
|
214
|
+
## JSON output
|
|
215
|
+
|
|
216
|
+
`list`, `show`, `use` and `check` take `--json`, which prints the result as JSON on stdout so a script or an AI agent can read it.
|
|
217
|
+
With `--json` nothing is printed except the JSON: no blank lines, no colors, and no warnings on stderr. What a warning
|
|
218
|
+
would have said is a field in the output instead.
|
|
219
|
+
|
|
220
|
+
`add`, `edit` and `remove` are interactive and do not take `--json`.
|
|
221
|
+
|
|
222
|
+
| Command | Output |
|
|
223
|
+
| --- | --- |
|
|
224
|
+
| `git who list --json` | An array of profiles. Each has the profile's fields and `active`, true for the profile in use in the current repo. |
|
|
225
|
+
| `git who show [name] --json` | `{ profile, derived, signingKeyStatus, mismatches }` |
|
|
226
|
+
| `git who use <name> --json` | `{ profile, originUpdated, origin }` |
|
|
227
|
+
| `git who check --json` | `{ profile, derived }` when the repo matches its profile. A mismatch is an error, like any other failure. |
|
|
228
|
+
|
|
229
|
+
A field with no value is left out of the object. `signingMethod` and `signingKey` are absent for a profile that doesn't
|
|
230
|
+
sign.
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
$ git who show --json
|
|
234
|
+
{
|
|
235
|
+
"profile": {
|
|
236
|
+
"name": "work",
|
|
237
|
+
"host": "github-work",
|
|
238
|
+
"username": "Bryan Work",
|
|
239
|
+
"email": "bryan@work.com",
|
|
240
|
+
"signingMethod": "ssh",
|
|
241
|
+
"signingKey": "/Users/bryan/.ssh/id_work.pub"
|
|
242
|
+
},
|
|
243
|
+
"derived": false,
|
|
244
|
+
"signingKeyStatus": { "found": true },
|
|
245
|
+
"mismatches": { "email": "bryan@personal.com" }
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **`derived`** is true when the profile was matched from the repo's `user.name` and `user.email`, not named by
|
|
250
|
+
`gitwho.profile`.
|
|
251
|
+
- **`signingKeyStatus`** is only present for a profile that signs. `found` is false when the key isn't on this machine.
|
|
252
|
+
For GPG, `uids` lists the identities on the key.
|
|
253
|
+
- **`mismatches`** is only present when the profile comes from the repo (`show` with no name). It lists the fields where
|
|
254
|
+
the repo's git config differs from the profile, with the value git has. `null` means git has nothing set. An empty
|
|
255
|
+
object means the repo matches the profile.
|
|
256
|
+
|
|
257
|
+
### `use --json`
|
|
258
|
+
|
|
259
|
+
`use --json` never prompts, so it needs a profile name and fails without one. The prompt for a mismatched `origin` is
|
|
260
|
+
replaced by a flag:
|
|
261
|
+
|
|
262
|
+
- Without `--update-origin`, `origin` is left alone. If it doesn't use the profile's SSH host, the output has
|
|
263
|
+
`origin: { current, expected }` and `originUpdated` is false.
|
|
264
|
+
- With `--update-origin`, `origin` is rewritten to `expected` and `originUpdated` is true.
|
|
265
|
+
|
|
266
|
+
`--update-origin` also works without `--json`, where it answers yes to the prompt.
|
|
267
|
+
|
|
268
|
+
### Errors
|
|
269
|
+
|
|
270
|
+
With `--json`, a failure is printed as JSON on stdout and the exit code is still `1`:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
{
|
|
274
|
+
"error": "Failed to apply profile 'work'. Git config may be partly updated; re-run to retry.",
|
|
275
|
+
"causes": ["addConfig failed"]
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`causes` holds the messages of the wrapped errors, outermost first, and is empty when there are none.
|
|
280
|
+
|
|
281
|
+
### Agent skill
|
|
282
|
+
|
|
283
|
+
The package includes a skill that teaches an AI agent to use these commands: `skills/git-who/SKILL.md`, in the installed
|
|
284
|
+
package. Copy or link the `git-who` folder into your agent's skills directory (for Claude Code, `~/.claude/skills/`).
|
|
285
|
+
|
|
170
286
|
## Where profiles are stored
|
|
171
287
|
|
|
172
288
|
Profiles live in `~/.git-who.cfg.json`, as a JSON array. You can edit it by hand, but `git who` validates it on every
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bstopp/git-who",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Manage git profiles for per-project SSH account configuration.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
-
"dist"
|
|
22
|
+
"dist",
|
|
23
|
+
"skills"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
package/dist/src/cli.js
CHANGED
|
@@ -4,6 +4,8 @@ import pkg from '../package.json' with { type: 'json' };
|
|
|
4
4
|
import show from "./commands/show.js";
|
|
5
5
|
import list from "./commands/list.js";
|
|
6
6
|
import use from "./commands/use.js";
|
|
7
|
+
import check from "./commands/check.js";
|
|
8
|
+
import { install, uninstall } from "./commands/hook.js";
|
|
7
9
|
import add from "./commands/add.js";
|
|
8
10
|
import edit from "./commands/edit.js";
|
|
9
11
|
import remove from "./commands/remove.js";
|
|
@@ -32,6 +34,22 @@ program
|
|
|
32
34
|
.option('--json', 'Print the result as JSON, without prompting (a profile name is required)')
|
|
33
35
|
.option('--update-origin', 'Update the origin remote to match the profile, without asking')
|
|
34
36
|
.action(use);
|
|
37
|
+
program
|
|
38
|
+
.command('check')
|
|
39
|
+
.description('Check that the git config of this repo matches its profile, failing if not. For use in a pre-commit hook.')
|
|
40
|
+
.option('--json', 'Print the result as JSON')
|
|
41
|
+
.action(check);
|
|
42
|
+
const hook = program
|
|
43
|
+
.command('hook')
|
|
44
|
+
.description('Manage the pre-commit hook that runs `git who check` in this repo.');
|
|
45
|
+
hook
|
|
46
|
+
.command('install')
|
|
47
|
+
.description('Run `git who check` before each commit in this repo.')
|
|
48
|
+
.action(() => install());
|
|
49
|
+
hook
|
|
50
|
+
.command('uninstall')
|
|
51
|
+
.description('Stop running `git who check` before each commit in this repo.')
|
|
52
|
+
.action(() => uninstall());
|
|
35
53
|
program
|
|
36
54
|
.command('add')
|
|
37
55
|
.description('Add a new profile.')
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
import log from "../utils/log.js";
|
|
3
|
+
import { loadProfiles } from "../utils/profiles.js";
|
|
4
|
+
import { resolveFromGit } from "../utils/repo-profile.js";
|
|
5
|
+
const NOT_SET = '(not set)';
|
|
6
|
+
const quote = (value) => value === undefined ? NOT_SET : `'${value}'`;
|
|
7
|
+
/**
|
|
8
|
+
* One line per setting that differs: what the profile expects and what git has. A profile without a signing key
|
|
9
|
+
* expects the signing settings to be unset.
|
|
10
|
+
*/
|
|
11
|
+
function describeMismatches(profile, mismatches) {
|
|
12
|
+
const settings = [
|
|
13
|
+
['username', 'user.name', profile.username],
|
|
14
|
+
['email', 'user.email', profile.email],
|
|
15
|
+
['signingKey', 'user.signingKey', profile.signingKey],
|
|
16
|
+
['signingMethod', 'commit signing', profile.signingMethod],
|
|
17
|
+
];
|
|
18
|
+
return settings
|
|
19
|
+
.filter(([field]) => field in mismatches)
|
|
20
|
+
.map(([field, label, expected]) => ` ${label}: expected ${quote(expected)}, found ${quote(mismatches[field])}`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Check that the current repo's git config matches its profile, for use in a pre-commit hook. A passing check prints
|
|
24
|
+
* nothing, so a hook adds no noise to a commit, unless `json` is set, which prints the profile that matched.
|
|
25
|
+
*
|
|
26
|
+
* @throws {Error} If the current directory is not a git repo, no profile can be resolved for it, or the git config
|
|
27
|
+
* differs from the profile. The message of the last names each setting that differs.
|
|
28
|
+
*/
|
|
29
|
+
export default async function check(options = {}) {
|
|
30
|
+
if (!await simpleGit().checkIsRepo()) {
|
|
31
|
+
throw new Error('The current directory must have a git repository to check a profile.');
|
|
32
|
+
}
|
|
33
|
+
const { profile, derived, mismatches } = await resolveFromGit(await loadProfiles());
|
|
34
|
+
if (mismatches && Object.keys(mismatches).length > 0) {
|
|
35
|
+
const lines = describeMismatches(profile, mismatches);
|
|
36
|
+
throw new Error([`Git config does not match profile '${profile.name}':`, ...lines].join('\n'));
|
|
37
|
+
}
|
|
38
|
+
if (options.json) {
|
|
39
|
+
log.json({ profile, derived });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
import log from "../utils/log.js";
|
|
3
|
+
const EVENT = 'pre-commit';
|
|
4
|
+
const HOOK_NAME = 'git-who';
|
|
5
|
+
const COMMAND = 'git who check';
|
|
6
|
+
const COMMAND_KEY = `hook.${HOOK_NAME}.command`;
|
|
7
|
+
const EVENT_KEY = `hook.${HOOK_NAME}.event`;
|
|
8
|
+
/**
|
|
9
|
+
* Whether git will run the hook for the event. A git that has no config hooks does not list it, and silently never
|
|
10
|
+
* runs it, so the install is only trusted once git says it sees the hook. `git hook list` also fails when nothing is
|
|
11
|
+
* listed, so a failure reads as not listed.
|
|
12
|
+
*/
|
|
13
|
+
async function isListed(git) {
|
|
14
|
+
try {
|
|
15
|
+
const output = await git.raw(['hook', 'list', EVENT]);
|
|
16
|
+
return output.split('\n').some((line) => line.trim() === HOOK_NAME);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function removeConfig(git) {
|
|
23
|
+
await git.raw(['config', '--local', '--unset', COMMAND_KEY]);
|
|
24
|
+
await git.raw(['config', '--local', '--unset-all', EVENT_KEY]);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Make the current repo run `git who check` before each commit, using git's config hooks, so any `pre-commit` hook
|
|
28
|
+
* file the repo already has (such as husky's) keeps running too. Installing again is harmless.
|
|
29
|
+
*
|
|
30
|
+
* @throws {Error} If the current directory is not a git repo, or git does not list the hook once it is set, which
|
|
31
|
+
* means it is too old to run config hooks. The config is put back in that case.
|
|
32
|
+
*/
|
|
33
|
+
export async function install() {
|
|
34
|
+
const git = simpleGit();
|
|
35
|
+
if (!await git.checkIsRepo()) {
|
|
36
|
+
throw new Error('The current directory must have a git repository to install the hook.');
|
|
37
|
+
}
|
|
38
|
+
await git.addConfig(COMMAND_KEY, COMMAND, false, 'local');
|
|
39
|
+
await git.addConfig(EVENT_KEY, EVENT, false, 'local');
|
|
40
|
+
if (!await isListed(git)) {
|
|
41
|
+
await removeConfig(git);
|
|
42
|
+
throw new Error('Git did not list the hook once it was installed, so it may be too old to run config hooks (hook.<name>.*). The hook was not installed.');
|
|
43
|
+
}
|
|
44
|
+
log.info(`Installed the ${EVENT} hook. Commits in this repo now run \`${COMMAND}\`.`);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Take the hook that `install` set out of the current repo's config. A `pre-commit` hook file is never touched.
|
|
48
|
+
*
|
|
49
|
+
* @throws {Error} If the current directory is not a git repo.
|
|
50
|
+
*/
|
|
51
|
+
export async function uninstall() {
|
|
52
|
+
const git = simpleGit();
|
|
53
|
+
if (!await git.checkIsRepo()) {
|
|
54
|
+
throw new Error('The current directory must have a git repository to uninstall the hook.');
|
|
55
|
+
}
|
|
56
|
+
const { value } = await git.getConfig(COMMAND_KEY, 'local');
|
|
57
|
+
if (!value) {
|
|
58
|
+
log.info(`The ${EVENT} hook is not installed.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await removeConfig(git);
|
|
62
|
+
log.info(`Removed the ${EVENT} hook.`);
|
|
63
|
+
}
|
|
@@ -2,88 +2,10 @@ import { simpleGit } from 'simple-git';
|
|
|
2
2
|
import { SIGNING_METHOD } from "../profile.js";
|
|
3
3
|
import log from "../utils/log.js";
|
|
4
4
|
import { printProfile } from "../utils/display.js";
|
|
5
|
-
import { loadProfiles
|
|
5
|
+
import { loadProfiles } from "../utils/profiles.js";
|
|
6
|
+
import { resolveFromGit } from "../utils/repo-profile.js";
|
|
6
7
|
import { listSshKeys } from "../utils/ssh.js";
|
|
7
8
|
import { listGpgKeys } from "../utils/gpg.js";
|
|
8
|
-
/**
|
|
9
|
-
* Compare a profile with the repo's local git config. A field is only present in the result when the two differ, and
|
|
10
|
-
* its value is what git has (`undefined` when git has nothing set).
|
|
11
|
-
*
|
|
12
|
-
* Git's signing method is only defined when `commit.gpgsign` is `true`: `gpg.format` of `openpgp` (or unset) is gpg,
|
|
13
|
-
* `ssh` is ssh. Any other format, such as x509, is not supported and reads as git not signing.
|
|
14
|
-
*/
|
|
15
|
-
function buildMismatches(actual, git) {
|
|
16
|
-
const mismatches = {};
|
|
17
|
-
if (actual.username !== git.username) {
|
|
18
|
-
mismatches.username = git.username;
|
|
19
|
-
}
|
|
20
|
-
if (actual.email !== git.email) {
|
|
21
|
-
mismatches.email = git.email;
|
|
22
|
-
}
|
|
23
|
-
if (actual.signingKey !== git.signingKey) {
|
|
24
|
-
mismatches.signingKey = git.signingKey;
|
|
25
|
-
}
|
|
26
|
-
let gitSigningMethod;
|
|
27
|
-
if (git.signCommits === 'true') {
|
|
28
|
-
if (!git.signingMethod || git.signingMethod === 'openpgp') {
|
|
29
|
-
gitSigningMethod = SIGNING_METHOD.GPG;
|
|
30
|
-
}
|
|
31
|
-
else if (git.signingMethod === 'ssh') {
|
|
32
|
-
gitSigningMethod = SIGNING_METHOD.SSH;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (actual.signingMethod !== gitSigningMethod) {
|
|
36
|
-
mismatches.signingMethod = gitSigningMethod;
|
|
37
|
-
}
|
|
38
|
-
return mismatches;
|
|
39
|
-
}
|
|
40
|
-
async function readGitConfig() {
|
|
41
|
-
const git = simpleGit();
|
|
42
|
-
const [name, { value: username }, { value: email }, { value: signCommits }, { value: signingKey }, { value: signingMethod },] = await Promise.all([
|
|
43
|
-
getActiveProfileName(git),
|
|
44
|
-
git.getConfig('user.name', 'local'),
|
|
45
|
-
git.getConfig('user.email', 'local'),
|
|
46
|
-
git.getConfig('commit.gpgsign', 'local'),
|
|
47
|
-
git.getConfig('user.signingKey', 'local'),
|
|
48
|
-
git.getConfig('gpg.format', 'local'),
|
|
49
|
-
]);
|
|
50
|
-
return {
|
|
51
|
-
name,
|
|
52
|
-
username: username ?? undefined,
|
|
53
|
-
email: email ?? undefined,
|
|
54
|
-
signCommits: signCommits ?? undefined,
|
|
55
|
-
signingKey: signingKey ?? undefined,
|
|
56
|
-
signingMethod: signingMethod ?? undefined,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Find the profile for the current repo: the one named by `gitwho.profile`, otherwise the single profile whose
|
|
61
|
-
* username and email match the git config (flagged as derived).
|
|
62
|
-
*
|
|
63
|
-
* @throws {Error} If `gitwho.profile` names a missing profile and nothing else matches uniquely, if several profiles
|
|
64
|
-
* match with no active profile set, or if no profile is set and none match. A missing active profile takes priority
|
|
65
|
-
* over multiple matches, since it is the underlying cause.
|
|
66
|
-
*/
|
|
67
|
-
async function resolveFromGit(profiles) {
|
|
68
|
-
const gitData = await readGitConfig();
|
|
69
|
-
const profile = profiles.find((p) => p.name === gitData.name);
|
|
70
|
-
if (profile) {
|
|
71
|
-
const mismatches = buildMismatches(profile, gitData);
|
|
72
|
-
return { profile, derived: false, mismatches };
|
|
73
|
-
}
|
|
74
|
-
const matches = profiles.filter((p) => p.username === gitData.username && p.email === gitData.email);
|
|
75
|
-
if (matches.length === 1) {
|
|
76
|
-
const mismatches = buildMismatches(matches[0], gitData);
|
|
77
|
-
return { profile: matches[0], derived: true, mismatches };
|
|
78
|
-
}
|
|
79
|
-
else if (gitData.name) {
|
|
80
|
-
throw new Error(`Active profile '${gitData.name}' not found — it may have been deleted.`);
|
|
81
|
-
}
|
|
82
|
-
else if (matches.length > 1) {
|
|
83
|
-
throw new Error('Multiple profiles match the current git config — set one explicitly with `git who use <profile>`.');
|
|
84
|
-
}
|
|
85
|
-
throw new Error('No profile set and none found matching the current git config.');
|
|
86
|
-
}
|
|
87
9
|
async function buildStatus(resolved) {
|
|
88
10
|
let signingKeyStatus;
|
|
89
11
|
if (resolved.profile.signingMethod === SIGNING_METHOD.SSH) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
import { SIGNING_METHOD } from "../profile.js";
|
|
3
|
+
import { getActiveProfileName } from "./profiles.js";
|
|
4
|
+
/**
|
|
5
|
+
* Compare a profile with the repo's local git config. A field is only present in the result when the two differ, and
|
|
6
|
+
* its value is what git has (`undefined` when git has nothing set).
|
|
7
|
+
*
|
|
8
|
+
* Git's signing method is only defined when `commit.gpgsign` is `true`: `gpg.format` of `openpgp` (or unset) is gpg,
|
|
9
|
+
* `ssh` is ssh. Any other format, such as x509, is not supported and reads as git not signing.
|
|
10
|
+
*/
|
|
11
|
+
function buildMismatches(actual, git) {
|
|
12
|
+
const mismatches = {};
|
|
13
|
+
if (actual.username !== git.username) {
|
|
14
|
+
mismatches.username = git.username;
|
|
15
|
+
}
|
|
16
|
+
if (actual.email !== git.email) {
|
|
17
|
+
mismatches.email = git.email;
|
|
18
|
+
}
|
|
19
|
+
if (actual.signingKey !== git.signingKey) {
|
|
20
|
+
mismatches.signingKey = git.signingKey;
|
|
21
|
+
}
|
|
22
|
+
let gitSigningMethod;
|
|
23
|
+
if (git.signCommits === 'true') {
|
|
24
|
+
if (!git.signingMethod || git.signingMethod === 'openpgp') {
|
|
25
|
+
gitSigningMethod = SIGNING_METHOD.GPG;
|
|
26
|
+
}
|
|
27
|
+
else if (git.signingMethod === 'ssh') {
|
|
28
|
+
gitSigningMethod = SIGNING_METHOD.SSH;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (actual.signingMethod !== gitSigningMethod) {
|
|
32
|
+
mismatches.signingMethod = gitSigningMethod;
|
|
33
|
+
}
|
|
34
|
+
return mismatches;
|
|
35
|
+
}
|
|
36
|
+
async function readGitConfig() {
|
|
37
|
+
const git = simpleGit();
|
|
38
|
+
const [name, { value: username }, { value: email }, { value: signCommits }, { value: signingKey }, { value: signingMethod },] = await Promise.all([
|
|
39
|
+
getActiveProfileName(git),
|
|
40
|
+
git.getConfig('user.name', 'local'),
|
|
41
|
+
git.getConfig('user.email', 'local'),
|
|
42
|
+
git.getConfig('commit.gpgsign', 'local'),
|
|
43
|
+
git.getConfig('user.signingKey', 'local'),
|
|
44
|
+
git.getConfig('gpg.format', 'local'),
|
|
45
|
+
]);
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
username: username ?? undefined,
|
|
49
|
+
email: email ?? undefined,
|
|
50
|
+
signCommits: signCommits ?? undefined,
|
|
51
|
+
signingKey: signingKey ?? undefined,
|
|
52
|
+
signingMethod: signingMethod ?? undefined,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Find the profile for the current repo: the one named by `gitwho.profile`, otherwise the single profile whose
|
|
57
|
+
* username and email match the git config (flagged as derived).
|
|
58
|
+
*
|
|
59
|
+
* @throws {Error} If `gitwho.profile` names a missing profile and nothing else matches uniquely, if several profiles
|
|
60
|
+
* match with no active profile set, or if no profile is set and none match. A missing active profile takes priority
|
|
61
|
+
* over multiple matches, since it is the underlying cause.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveFromGit(profiles) {
|
|
64
|
+
const gitData = await readGitConfig();
|
|
65
|
+
const profile = profiles.find((p) => p.name === gitData.name);
|
|
66
|
+
if (profile) {
|
|
67
|
+
const mismatches = buildMismatches(profile, gitData);
|
|
68
|
+
return { profile, derived: false, mismatches };
|
|
69
|
+
}
|
|
70
|
+
const matches = profiles.filter((p) => p.username === gitData.username && p.email === gitData.email);
|
|
71
|
+
if (matches.length === 1) {
|
|
72
|
+
const mismatches = buildMismatches(matches[0], gitData);
|
|
73
|
+
return { profile: matches[0], derived: true, mismatches };
|
|
74
|
+
}
|
|
75
|
+
else if (gitData.name) {
|
|
76
|
+
throw new Error(`Active profile '${gitData.name}' not found — it may have been deleted.`);
|
|
77
|
+
}
|
|
78
|
+
else if (matches.length > 1) {
|
|
79
|
+
throw new Error('Multiple profiles match the current git config — set one explicitly with `git who use <profile>`.');
|
|
80
|
+
}
|
|
81
|
+
throw new Error('No profile set and none found matching the current git config.');
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bstopp/git-who",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Manage git profiles for per-project SSH account configuration.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
-
"dist"
|
|
22
|
+
"dist",
|
|
23
|
+
"skills"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
26
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: git-who
|
|
3
|
+
description: Check or switch which git identity a repo uses (SSH host, user.name, user.email, commit signing) with the `git who` CLI. Use before committing or pushing in a repo that may belong to another account, when a commit went out under the wrong name or email, or when asked to use a named profile such as work or personal.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# git-who
|
|
7
|
+
|
|
8
|
+
`git who` keeps named **profiles**. A profile is an SSH host alias, a `user.name`, a `user.email` and optionally a
|
|
9
|
+
commit-signing key. Applying a profile to a repo writes those into the repo's local git config. Use it so that commits
|
|
10
|
+
and pushes in a repo go out under the right account.
|
|
11
|
+
|
|
12
|
+
Always pass `--json` and read stdout. Without it the commands print text meant for people, and some of them prompt.
|
|
13
|
+
|
|
14
|
+
## Commands you may run
|
|
15
|
+
|
|
16
|
+
| Command | Use it to |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `git who list --json` | See which profiles exist and which one the current repo uses (`active`). |
|
|
19
|
+
| `git who show --json` | Check the current repo against its profile. |
|
|
20
|
+
| `git who show <name> --json` | Read one profile's details. |
|
|
21
|
+
| `git who use <name> --json` | Apply a profile to the current repo. |
|
|
22
|
+
| `git who check --json` | Pass or fail: does the current repo match its profile? Exits non-zero, with the differences in the error, if not. |
|
|
23
|
+
|
|
24
|
+
Never run `git who add`, `edit` or `remove`. They are interactive and meant for the user. If a profile is missing or
|
|
25
|
+
wrong, tell the user and let them run those. Do not run `git who hook install` or `hook uninstall` either: they change
|
|
26
|
+
what every commit in the repo does, so that is the user's decision.
|
|
27
|
+
|
|
28
|
+
## Checking a repo
|
|
29
|
+
|
|
30
|
+
Run `git who show --json` in the repo.
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"profile": { "name": "work", "host": "github-work", "username": "Bryan Work", "email": "bryan@work.com" },
|
|
35
|
+
"derived": false,
|
|
36
|
+
"signingKeyStatus": { "found": true },
|
|
37
|
+
"mismatches": { "email": "bryan@personal.com" }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `profile` is the profile the repo is set to use.
|
|
42
|
+
- `derived: true` means no profile is set on the repo, and this one was only matched by name and email. Apply it with
|
|
43
|
+
`use` if it is the right one.
|
|
44
|
+
- `mismatches` lists the fields where the repo's git config differs from the profile, with the value git currently has.
|
|
45
|
+
`null` means git has nothing set. `{}` means the repo is correct. If there are mismatches, the repo has drifted, and
|
|
46
|
+
`use` fixes it.
|
|
47
|
+
- `signingKeyStatus.found: false` means the profile's signing key is not on this machine. Commits will not be signed as
|
|
48
|
+
the profile expects. Tell the user, because you cannot fix that.
|
|
49
|
+
- `signingMethod` and `signingKey` are missing from `profile` when the profile does not sign.
|
|
50
|
+
|
|
51
|
+
## Switching a repo
|
|
52
|
+
|
|
53
|
+
1. Run `git who list --json` and pick the profile whose `name` matches what the user asked for. If you are unsure which
|
|
54
|
+
one is meant, ask the user instead of guessing.
|
|
55
|
+
2. Run `git who use <name> --json`.
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{ "profile": { "name": "work", "...": "..." }, "originUpdated": false, "origin": { "current": "git@github-personal:org/repo.git", "expected": "git@github-work:org/repo.git" } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`origin` is only present when the repo's `origin` remote does not use the profile's host. Pushes would go out under
|
|
62
|
+
the other account. `use` leaves `origin` alone by default (`originUpdated: false`).
|
|
63
|
+
|
|
64
|
+
- Do **not** add `--update-origin` on your own. It rewrites the remote. Say what `current` and `expected` are, and add
|
|
65
|
+
the flag only if the user agrees or already asked for it.
|
|
66
|
+
- If the repo is correct afterwards, `git who show --json` shows `mismatches: {}`.
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
A failure prints `{ "error": "...", "causes": ["..."] }` on stdout and exits with code 1. `causes` lists the wrapped
|
|
71
|
+
errors, outermost first.
|
|
72
|
+
|
|
73
|
+
| Error | What to do |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `A profile name is required with --json.` | Pass a profile name to `use`. |
|
|
76
|
+
| `Profile <name> not found.` | Run `list` and use one of its names. If none fits, ask the user. |
|
|
77
|
+
| `The current directory must have a git repository to use a profile.` | Run it from inside the repo. |
|
|
78
|
+
| `No profiles found. ...` | Nothing is set up. The user has to run `git who add`. |
|
|
79
|
+
| `... key '...' not found for profile '...'` | The profile's signing key is missing. Tell the user. Do not work around it. |
|
|
80
|
+
| `Git config does not match profile '<name>': ...` | From `check`, or from a commit stopped by the user's pre-commit hook. If `<name>` is clearly the right profile, run `git who use <name> --json` and retry the commit. If not, ask the user. |
|
|
81
|
+
| `Failed to apply profile ...` | The git config may be partly updated. Run `git who show --json` to see what is left, then retry `use`. |
|
|
82
|
+
|
|
83
|
+
## Rules
|
|
84
|
+
|
|
85
|
+
- Check with `show` before you commit or push in a repo where the identity matters, and fix it with `use` if it has
|
|
86
|
+
drifted.
|
|
87
|
+
- Do not edit `user.name`, `user.email` or the signing settings with `git config` to get around a `git who` error.
|
|
88
|
+
Report the error instead.
|
|
89
|
+
- Do not use `git commit --no-verify` to get past a stopped commit. The hook is there to catch the wrong identity, so
|
|
90
|
+
fix the identity or ask the user.
|
|
91
|
+
- Do not edit `~/.git-who.cfg.json` or the `gitwho.profile` setting by hand.
|