@bstopp/git-who 0.1.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 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.1.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";
@@ -19,15 +21,35 @@ program
19
21
  program
20
22
  .command('show [name]')
21
23
  .description('Show the current git profile for this repo')
24
+ .option('--json', 'Print the result as JSON')
22
25
  .action(show);
23
26
  program
24
27
  .command('list')
25
28
  .description('List all available profiles')
29
+ .option('--json', 'Print the result as JSON')
26
30
  .action(list);
27
31
  program
28
32
  .command('use [name]')
29
33
  .description('Set the profile for this repo.')
34
+ .option('--json', 'Print the result as JSON, without prompting (a profile name is required)')
35
+ .option('--update-origin', 'Update the origin remote to match the profile, without asking')
30
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());
31
53
  program
32
54
  .command('add')
33
55
  .description('Add a new profile.')
@@ -45,16 +67,19 @@ program
45
67
  .description('Clone a remote repo using a specific profile.')
46
68
  .action(clone);
47
69
  /**
48
- * Join an error's message with those of its `cause` chain, one per line, so wrapped failures show the real reason.
70
+ * The message of an error followed by those of its `cause` chain, so wrapped failures show the real reason.
49
71
  */
50
- function describeError(err) {
51
- const lines = [];
72
+ function errorMessages(err) {
73
+ const messages = [];
52
74
  let current = err;
53
75
  while (current instanceof Error) {
54
- lines.push(lines.length === 0 ? current.message : `Caused by: ${current.message}`);
76
+ messages.push(current.message);
55
77
  current = current.cause;
56
78
  }
57
- return lines.length > 0 ? lines.join('\n') : String(err);
79
+ return messages.length > 0 ? messages : [String(err)];
80
+ }
81
+ function describeError(err) {
82
+ return errorMessages(err).map((message, i) => i === 0 ? message : `Caused by: ${message}`).join('\n');
58
83
  }
59
84
  /**
60
85
  * Run the cli, printing any failure (with its cause chain) and setting a non-zero exit code. A cancelled prompt
@@ -69,7 +94,13 @@ export async function run(argv) {
69
94
  process.exitCode = SIGINT_EXIT_CODE;
70
95
  return;
71
96
  }
72
- log.error(describeError(err));
97
+ if (argv.includes('--json')) {
98
+ const [error, ...causes] = errorMessages(err);
99
+ log.json({ error, causes });
100
+ }
101
+ else {
102
+ log.error(describeError(err));
103
+ }
73
104
  process.exitCode = 1;
74
105
  }
75
106
  }
@@ -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,9 +2,13 @@ import chalk from 'chalk';
2
2
  import { simpleGit } from 'simple-git';
3
3
  import log from "../utils/log.js";
4
4
  import { loadProfiles, getActiveProfileName } from "../utils/profiles.js";
5
- export default async function list() {
5
+ export default async function list(options = {}) {
6
6
  const profiles = await loadProfiles();
7
7
  const activeName = await getActiveProfileName(simpleGit());
8
+ if (options.json) {
9
+ log.json(profiles.map((p) => ({ ...p, active: p.name === activeName })));
10
+ return;
11
+ }
8
12
  const lines = profiles.map((p) => {
9
13
  if (p.name === activeName) {
10
14
  return chalk.bold(` - ${p.name} (${p.host})`);
@@ -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, getActiveProfileName } from "../utils/profiles.js";
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) {
@@ -128,14 +50,43 @@ async function resolveProfile(name, profiles) {
128
50
  return await resolveFromGit(profiles);
129
51
  }
130
52
  }
53
+ /**
54
+ * The result as a plain object for `--json`. The warnings that are printed as text become fields, so nothing is lost
55
+ * from stdout. A mismatched field git has no value for is `null`, which JSON would otherwise drop from the object.
56
+ */
57
+ function toJson(resolved, signingKeyStatus) {
58
+ const { profile, derived, mismatches } = resolved;
59
+ return {
60
+ profile,
61
+ derived,
62
+ signingKeyStatus,
63
+ mismatches: mismatches && Object.fromEntries(Object.entries(mismatches).map(([field, value]) => [field, value ?? null])),
64
+ };
65
+ }
66
+ /**
67
+ * The extras for `printProfile`, or `undefined` when there are none, so a plain profile is printed without them.
68
+ */
69
+ function displayOptions(resolved, signingKeyStatus) {
70
+ let opts = undefined;
71
+ if (resolved.mismatches && Object.keys(resolved.mismatches).length > 0) {
72
+ opts ||= {};
73
+ opts.mismatches = resolved.mismatches;
74
+ }
75
+ if (signingKeyStatus) {
76
+ opts ||= {};
77
+ opts.signingKeyStatus = signingKeyStatus;
78
+ }
79
+ return opts;
80
+ }
131
81
  /**
132
82
  * Print a profile, either the one given by name or the one resolved from the current repo. A profile resolved from the
133
- * repo is also checked against its git config, and any signing key is looked up to confirm it exists.
83
+ * repo is also checked against its git config, and any signing key is looked up to confirm it exists. With `json` the
84
+ * result is printed as JSON instead.
134
85
  *
135
86
  * @throws {Error} If no name is given outside a git repo, the named profile does not exist, or no profile can be
136
87
  * resolved from the repo. Errors from the ssh and gpg key lookups are not caught.
137
88
  */
138
- export default async function show(name) {
89
+ export default async function show(name, options = {}) {
139
90
  const git = simpleGit();
140
91
  if (!name && !(await git.checkIsRepo())) {
141
92
  throw new Error('You must provide a profile name, or invoke the show command from a git repo.');
@@ -143,16 +94,11 @@ export default async function show(name) {
143
94
  const profiles = await loadProfiles();
144
95
  const resolved = await resolveProfile(name, profiles);
145
96
  const signingKeyStatus = await buildStatus(resolved);
146
- let opts = undefined;
147
- if (resolved.mismatches && Object.keys(resolved.mismatches).length > 0) {
148
- opts ||= {};
149
- opts.mismatches = resolved.mismatches;
150
- }
151
- if (signingKeyStatus) {
152
- opts ||= {};
153
- opts.signingKeyStatus = signingKeyStatus;
97
+ if (options.json) {
98
+ log.json(toJson(resolved, signingKeyStatus));
99
+ return;
154
100
  }
155
- printProfile(resolved.profile, opts);
101
+ printProfile(resolved.profile, displayOptions(resolved, signingKeyStatus));
156
102
  if (resolved.derived) {
157
103
  log.warn(`Profile derived from git config — not explicitly set via gitwho.profile`);
158
104
  }
@@ -11,54 +11,74 @@ function extractRepoPath(url) {
11
11
  return parseSshUrl(url)?.path ?? url.match(HTTPS_URL_PATTERN)?.[1];
12
12
  }
13
13
  /**
14
- * Work out the origin remote url to set, if any. When the origin does not use the profile's ssh host, the user is asked
15
- * whether to rewrite it to `git@<host>:<path>`.
14
+ * Find where the origin remote differs from the url the profile's ssh host would give it, `git@<host>:<path>`.
16
15
  *
17
- * @returns The url to set, or `undefined` to leave the origin alone (no origin, already matching, or declined).
16
+ * @returns The current and expected urls, or `undefined` when there is no origin or it already matches.
18
17
  * @throws {Error} If the origin is neither an ssh nor an http(s) url, so its repo path cannot be determined.
19
18
  */
20
- async function resolveOriginUrl(git, profile) {
21
- const { value: currentUrl } = await git.getConfig('remote.origin.url', 'local');
22
- if (!currentUrl) {
19
+ async function checkOrigin(git, profile) {
20
+ const { value: current } = await git.getConfig('remote.origin.url', 'local');
21
+ if (!current) {
23
22
  return undefined;
24
23
  }
25
- const path = extractRepoPath(currentUrl);
24
+ const path = extractRepoPath(current);
26
25
  if (!path) {
27
- throw new Error(`Unable to determine the repo path from origin remote '${currentUrl}'.`);
28
- }
29
- const desiredUrl = `git@${profile.host}:${path}`;
30
- if (desiredUrl === currentUrl) {
31
- return undefined;
26
+ throw new Error(`Unable to determine the repo path from origin remote '${current}'.`);
32
27
  }
28
+ const expected = `git@${profile.host}:${path}`;
29
+ return expected === current ? undefined : { current, expected };
30
+ }
31
+ function confirmOriginUpdate(profile, { current, expected }) {
33
32
  const { style } = makeTheme();
34
- const updateOrigin = await confirm({
35
- message: `Origin remote is ${style.highlight(currentUrl)}, but profile '${profile.name}' expects ${style.highlight(desiredUrl)}. Update origin to match?`,
33
+ return confirm({
34
+ message: `Origin remote is ${style.highlight(current)}, but profile '${profile.name}' expects ${style.highlight(expected)}. Update origin to match?`,
36
35
  default: true,
37
36
  });
38
- return updateOrigin ? desiredUrl : undefined;
39
37
  }
40
38
  /**
41
- * Apply a profile to the current repo, either the one given by name or one picked from a prompt.
39
+ * Look a profile up by name without falling back to a prompt, for output that nobody is there to answer.
40
+ */
41
+ function findProfile(profiles, name) {
42
+ if (!name) {
43
+ throw new Error('A profile name is required with --json.');
44
+ }
45
+ const profile = profiles.find((p) => p.name === name);
46
+ if (!profile) {
47
+ throw new Error(`Profile ${name} not found.`);
48
+ }
49
+ return profile;
50
+ }
51
+ /**
52
+ * Apply a profile to the current repo, either the one given by name or one picked from a prompt. A mismatched origin is
53
+ * rewritten when `updateOrigin` is set, otherwise the user is asked, except with `json`, which never prompts: it needs
54
+ * a name and leaves the origin alone unless `updateOrigin` is set.
42
55
  *
43
- * @throws {Error} If the current directory is not a git repo, the profile's signing key is missing or cannot be looked
44
- * up, the origin remote url cannot be parsed, or applying the profile fails. The signing key is checked first and its
45
- * failure is thrown as is, because nothing has been written by then. A failure to apply is wrapped with its `cause`,
46
- * since the git config may be left partly updated.
56
+ * @throws {Error} If the current directory is not a git repo, `json` has no name or the name is unknown, the profile's
57
+ * signing key is missing or cannot be looked up, the origin remote url cannot be parsed, or applying the profile fails.
58
+ * The signing key is checked first and its failure is thrown as is, because nothing has been written by then. A failure
59
+ * to apply is wrapped with its `cause`, since the git config may be left partly updated.
47
60
  */
48
- export default async function use(name) {
61
+ export default async function use(name, options = {}) {
49
62
  const git = simpleGit();
50
63
  if (!await git.checkIsRepo()) {
51
64
  throw new Error('The current directory must have a git repository to use a profile.');
52
65
  }
53
66
  const profiles = await loadProfiles();
54
- const profile = await selectProfile(profiles, name);
67
+ const profile = options.json ? findProfile(profiles, name) : await selectProfile(profiles, name);
55
68
  await verifySigningKey(profile);
56
- const originUrl = await resolveOriginUrl(git, profile);
69
+ const origin = await checkOrigin(git, profile);
70
+ const updateOrigin = origin !== undefined
71
+ && (options.updateOrigin || (!options.json && await confirmOriginUpdate(profile, origin)));
57
72
  try {
58
- await applyProfile(git, profile, originUrl);
73
+ await applyProfile(git, profile, updateOrigin ? origin.expected : undefined);
59
74
  }
60
75
  catch (error) {
61
76
  throw new Error(`Failed to apply profile '${profile.name}'. Git config may be partly updated; re-run to retry.`, { cause: error });
62
77
  }
63
- log.info(`Now using profile '${chalk.bold(profile.name)}'.`);
78
+ if (options.json) {
79
+ log.json({ profile, originUpdated: updateOrigin, origin });
80
+ }
81
+ else {
82
+ log.info(`Now using profile '${chalk.bold(profile.name)}'.`);
83
+ }
64
84
  }
@@ -18,5 +18,11 @@ const log = {
18
18
  info(msg) {
19
19
  console.log(`\n${msg}\n`);
20
20
  },
21
+ /**
22
+ * Writes a value to stdout as indented JSON, with no blank lines or colors so the output can be parsed as is.
23
+ */
24
+ json(value) {
25
+ console.log(JSON.stringify(value, null, 2));
26
+ },
21
27
  };
22
28
  export default log;
@@ -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.1.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.