@savvy-web/cli 3.5.0 → 3.6.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/cli/index.js +19 -3
- package/commands/changeset/commands/check.js +82 -21
- package/commands/changeset/commands/config-validate.js +9 -6
- package/commands/changeset/commands/deps-detect.js +2 -2
- package/commands/changeset/commands/deps-regen.js +94 -48
- package/commands/changeset/commands/init.js +56 -23
- package/commands/changeset/commands/transform.js +82 -8
- package/commands/changeset/commands/validate-file.js +12 -11
- package/commands/changeset/commands/version.js +48 -17
- package/commands/changeset/index.js +3 -3
- package/commands/changeset/utils/config-gate.js +12 -10
- package/commands/check-section.js +34 -0
- package/commands/check.js +48 -39
- package/commands/clean/progress.js +71 -0
- package/commands/clean/view.js +26 -0
- package/commands/clean.js +113 -50
- package/commands/commit/check.js +80 -77
- package/commands/commit/init.js +21 -10
- package/commands/commit/lint.js +2 -2
- package/commands/init.js +122 -34
- package/commands/lint/check.js +126 -171
- package/commands/lint/init.js +52 -33
- package/commands/lint/text.js +28 -13
- package/commands/repos/commands/add.js +14 -17
- package/commands/repos/commands/deregister.js +37 -18
- package/commands/repos/commands/note.js +78 -26
- package/commands/repos/commands/pin.js +39 -24
- package/commands/repos/commands/remove.js +30 -56
- package/commands/repos/commands/rename.js +32 -23
- package/commands/repos/commands/restore.js +42 -56
- package/commands/repos/commands/status.js +43 -47
- package/commands/repos/commands/sync.js +11 -40
- package/commands/repos/index.js +7 -10
- package/commands/repos/shared.js +129 -0
- package/index.d.ts +162 -61
- package/internal/command-error.js +60 -0
- package/internal/confirm.js +52 -0
- package/internal/failure-line.js +26 -12
- package/internal/report.js +101 -0
- package/main.js +23 -16
- package/package.json +17 -12
- package/version.js +1 -1
- package/internal/output.js +0 -52
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import {
|
|
4
|
+
import { CliInteractive, Doc } from "@effected/cli";
|
|
4
5
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
6
|
+
import { Effect, Option } from "effect";
|
|
6
7
|
|
|
7
8
|
//#region src/commands/repos/commands/pin.ts
|
|
8
9
|
/**
|
|
@@ -19,8 +20,14 @@ import { Effect } from "effect";
|
|
|
19
20
|
* `GitSubmoduleError` means the underlying git command failed,
|
|
20
21
|
* `RepoNotFoundError` means the named repo isn't in the manifest, and
|
|
21
22
|
* `ReposLockdownError` means the OS-permission lockdown pass on a vendored
|
|
22
|
-
* tree failed -- all four are real failures,
|
|
23
|
-
*
|
|
23
|
+
* tree failed -- all four are real failures, failing as a `CommandError`
|
|
24
|
+
* (exit 1) with a hint.
|
|
25
|
+
*
|
|
26
|
+
* Both positionals are optional to the parser so a person can leave them off:
|
|
27
|
+
* at a terminal a missing name is picked from the vendored repos (`Select`)
|
|
28
|
+
* and a missing ref typed (`TextInput`, starting at the current pin). Anywhere
|
|
29
|
+
* else (an agent, CI, a pipe) a missing one is the usage error it always was,
|
|
30
|
+
* exit 64, and nothing is read.
|
|
24
31
|
*
|
|
25
32
|
* @example
|
|
26
33
|
* ```bash
|
|
@@ -30,37 +37,45 @@ import { Effect } from "effect";
|
|
|
30
37
|
* @internal
|
|
31
38
|
*/
|
|
32
39
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
33
|
-
const nameArg = Argument.String("name");
|
|
34
|
-
const refArg = Argument.String("ref");
|
|
40
|
+
const nameArg = Argument.String("name").pipe(Argument.optional);
|
|
41
|
+
const refArg = Argument.String("ref").pipe(Argument.optional);
|
|
35
42
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to pin within"), Flag.withDefault("."));
|
|
36
43
|
/* v8 ignore stop */
|
|
37
44
|
/**
|
|
38
|
-
* Pin handler; exported for tests.
|
|
45
|
+
* Pin handler; exported for tests. `name`/`ref` are `undefined` when left off.
|
|
39
46
|
*
|
|
40
47
|
* @internal
|
|
41
48
|
*/
|
|
42
49
|
const runReposPin = (cwd, name, ref) => Effect.gen(function* () {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
})
|
|
50
|
+
const target = yield* ReposCli.nameOrPick(cwd, name, {
|
|
51
|
+
command: ["pin"],
|
|
52
|
+
argument: "name",
|
|
53
|
+
message: "Re-pin which repo?"
|
|
54
|
+
});
|
|
55
|
+
const current = ref === void 0 && (yield* CliInteractive) ? (yield* ReposCli.vendored(cwd)).find((repo) => repo.name === target)?.ref : void 0;
|
|
56
|
+
const newRef = yield* ReposCli.textOrAsk(ref, {
|
|
57
|
+
command: ["pin"],
|
|
58
|
+
argument: "ref",
|
|
59
|
+
message: `Re-pin ${target} to which ref? (tag, branch, or commit)`,
|
|
60
|
+
initial: current
|
|
61
|
+
});
|
|
62
|
+
const result = yield* (yield* Repos.ReposManager).pin(cwd, target, newRef);
|
|
63
|
+
yield* Report.print([
|
|
64
|
+
Report.ok(`${result.name}: ${result.oldCommit ?? "unknown"} -> ${result.newCommit}`, "staged — review and commit"),
|
|
65
|
+
Doc.codeBlock(result.commitMessage),
|
|
66
|
+
...result.staleNoteIds.map((staleId) => Report.warn(`note ${staleId} is now stale against ${newRef}`))
|
|
67
|
+
]);
|
|
68
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("re-pin the repo")(error)), Effect.catchTag([
|
|
69
|
+
"GitSubmoduleError",
|
|
70
|
+
"RepoNotFoundError",
|
|
71
|
+
"ReposLockdownError"
|
|
72
|
+
], ReposCli.fail("re-pin the repo")));
|
|
58
73
|
/* v8 ignore start -- CLI registration; handler tested via runReposPin */
|
|
59
74
|
const pinCommand = Command.make("pin", {
|
|
60
75
|
name: nameArg,
|
|
61
76
|
ref: refArg,
|
|
62
77
|
cwd: cwdOption
|
|
63
|
-
}, ({ name, ref, cwd }) => runReposPin(cwd, name, ref)).pipe(Command.withDescription("Re-pin a vendored repo to a new ref; stages the change without committing"));
|
|
78
|
+
}, ({ name, ref, cwd }) => runReposPin(cwd, Option.getOrUndefined(name), Option.getOrUndefined(ref))).pipe(Command.withDescription("Re-pin a vendored repo to a new ref; stages the change without committing"));
|
|
64
79
|
/* v8 ignore stop */
|
|
65
80
|
|
|
66
81
|
//#endregion
|
|
@@ -1,73 +1,47 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { confirmDestructive, yesFlag } from "../../../internal/confirm.js";
|
|
3
|
+
import { ReposCli } from "../shared.js";
|
|
2
4
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import {
|
|
5
|
+
import { Doc } from "@effected/cli";
|
|
4
6
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
7
|
+
import { Effect, Option } from "effect";
|
|
6
8
|
|
|
7
9
|
//#region src/commands/repos/commands/remove.ts
|
|
8
|
-
/**
|
|
9
|
-
* `repos remove` command -- unvendor a `.repos/` submodule.
|
|
10
|
-
*
|
|
11
|
-
* @remarks
|
|
12
|
-
* A thin adapter over {@link Repos.ReposManager.remove}: deinitializes and
|
|
13
|
-
* removes the submodule's gitlink, worktree, and module gitdir, drops its
|
|
14
|
-
* `.gitmodules` section, and removes the manifest entry -- it does not
|
|
15
|
-
* commit, so the caller reviews and commits the staged removal with the
|
|
16
|
-
* ready-made message the result carries. Any notes the entry carried are
|
|
17
|
-
* surfaced in the result so a durable one can be promoted elsewhere first.
|
|
18
|
-
*
|
|
19
|
-
* A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
|
|
20
|
-
* the common, friendly case and always exits 0. A `ReposConfigError` with
|
|
21
|
-
* kind `"invalid"` means the manifest exists but is corrupt or unreadable.
|
|
22
|
-
* `RepoNotFoundError` means the named repo isn't in the manifest -- this is
|
|
23
|
-
* a REAL failure (unlike the "missing manifest" case above), since removing
|
|
24
|
-
* a name that was never vendored is almost always a typo the caller should
|
|
25
|
-
* see. `GitSubmoduleError` means the underlying git command failed, and
|
|
26
|
-
* `ReposLockdownError` means the OS-permission lockdown pass on a vendored
|
|
27
|
-
* tree failed -- all four (invalid config, not-found, git failure, lockdown
|
|
28
|
-
* failure) are real failures, logged and reported via a non-zero exit code.
|
|
29
|
-
*
|
|
30
|
-
* @example
|
|
31
|
-
* ```bash
|
|
32
|
-
* savvy repos remove my-repo
|
|
33
|
-
* ```
|
|
34
|
-
*
|
|
35
|
-
* @internal
|
|
36
|
-
*/
|
|
37
10
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
38
|
-
const nameArg = Argument.String("name");
|
|
11
|
+
const nameArg = Argument.String("name").pipe(Argument.optional);
|
|
39
12
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to remove within"), Flag.withDefault("."));
|
|
40
|
-
/* v8 ignore stop */
|
|
41
13
|
/**
|
|
42
|
-
* Remove handler; exported for tests.
|
|
14
|
+
* Remove handler; exported for tests. `name` is `undefined` when left off.
|
|
43
15
|
*
|
|
44
16
|
* @internal
|
|
45
17
|
*/
|
|
46
|
-
const runReposRemove = (cwd, name) => Effect.gen(function* () {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
18
|
+
const runReposRemove = (cwd, name, options = {}) => Effect.gen(function* () {
|
|
19
|
+
const target = yield* ReposCli.nameOrPick(cwd, name, {
|
|
20
|
+
command: ["remove"],
|
|
21
|
+
argument: "name",
|
|
22
|
+
message: "Unvendor which repo?"
|
|
23
|
+
});
|
|
24
|
+
if (!(yield* confirmDestructive({
|
|
25
|
+
message: `Remove ${target} from .repos/? (staged, not committed)`,
|
|
26
|
+
yes: options.yes === true
|
|
27
|
+
}))) return yield* Report.print([Report.skip(`remove cancelled — ${target} left vendored`)]);
|
|
28
|
+
const result = yield* (yield* Repos.ReposManager).remove(cwd, target);
|
|
29
|
+
const blocks = [Report.ok(`${result.name}: removed (${result.path})`, "staged — review and commit"), Doc.codeBlock(result.commitMessage)];
|
|
30
|
+
const lost = result.removedNotes.map((note) => Doc.paragraph(`note ${note.id} (${note.ref}) was removed with the entry — promote it first if it is durable`));
|
|
31
|
+
if (result.removedEntry.orientation) lost.push(Doc.paragraph(`the orientation block for ${result.name} was removed with the entry and add will NOT restore it — re-vendoring? capture it now:`), Doc.codeBlock(JSON.stringify(result.removedEntry.orientation, null, 2), "json"));
|
|
32
|
+
if (lost.length > 0) blocks.push(Doc.callout("warning", lost));
|
|
33
|
+
yield* Report.print(blocks);
|
|
34
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("remove the repo")(error)), Effect.catchTag([
|
|
35
|
+
"RepoNotFoundError",
|
|
36
|
+
"GitSubmoduleError",
|
|
37
|
+
"ReposLockdownError"
|
|
38
|
+
], ReposCli.fail("remove the repo")));
|
|
66
39
|
/* v8 ignore start -- CLI registration; handler tested via runReposRemove */
|
|
67
40
|
const removeCommand = Command.make("remove", {
|
|
68
41
|
name: nameArg,
|
|
42
|
+
yes: yesFlag,
|
|
69
43
|
cwd: cwdOption
|
|
70
|
-
}, ({ name, cwd }) => runReposRemove(cwd, name)).pipe(Command.withDescription("Unvendor a repo under .repos/; stages the removal without committing"));
|
|
44
|
+
}, ({ name, yes, cwd }) => runReposRemove(cwd, Option.getOrUndefined(name), { yes })).pipe(Command.withDescription("Unvendor a repo under .repos/; stages the removal without committing"));
|
|
71
45
|
/* v8 ignore stop */
|
|
72
46
|
|
|
73
47
|
//#endregion
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import {
|
|
4
|
+
import { Doc } from "@effected/cli";
|
|
4
5
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
|
-
import { Effect } from "effect";
|
|
6
|
+
import { Effect, Option } from "effect";
|
|
6
7
|
|
|
7
8
|
//#region src/commands/repos/commands/rename.ts
|
|
8
9
|
/**
|
|
@@ -25,8 +26,13 @@ import { Effect } from "effect";
|
|
|
25
26
|
* should see. `GitSubmoduleError` means the underlying git command failed,
|
|
26
27
|
* and `ReposLockdownError` means the OS-permission lockdown pass on a
|
|
27
28
|
* vendored tree failed -- all four (invalid config, not-found, git failure,
|
|
28
|
-
* lockdown failure) are real failures,
|
|
29
|
-
*
|
|
29
|
+
* lockdown failure) are real failures, failing as a `CommandError` (exit 1)
|
|
30
|
+
* with a hint.
|
|
31
|
+
*
|
|
32
|
+
* Both positionals are optional to the parser so a person can leave them off:
|
|
33
|
+
* at a terminal a missing old name is picked from the vendored repos
|
|
34
|
+
* (`Select`) and a missing new name typed (`TextInput`). Anywhere else a
|
|
35
|
+
* missing one is the usage error it always was, exit 64.
|
|
30
36
|
*
|
|
31
37
|
* @example
|
|
32
38
|
* ```bash
|
|
@@ -36,36 +42,39 @@ import { Effect } from "effect";
|
|
|
36
42
|
* @internal
|
|
37
43
|
*/
|
|
38
44
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
39
|
-
const oldNameArg = Argument.String("old-name");
|
|
40
|
-
const newNameArg = Argument.String("new-name");
|
|
45
|
+
const oldNameArg = Argument.String("old-name").pipe(Argument.optional);
|
|
46
|
+
const newNameArg = Argument.String("new-name").pipe(Argument.optional);
|
|
41
47
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to rename within"), Flag.withDefault("."));
|
|
42
48
|
/* v8 ignore stop */
|
|
43
49
|
/**
|
|
44
|
-
* Rename handler; exported for tests.
|
|
50
|
+
* Rename handler; exported for tests. Either name is `undefined` when left off.
|
|
45
51
|
*
|
|
46
52
|
* @internal
|
|
47
53
|
*/
|
|
48
54
|
const runReposRename = (cwd, oldName, newName) => Effect.gen(function* () {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
const from = yield* ReposCli.nameOrPick(cwd, oldName, {
|
|
56
|
+
command: ["rename"],
|
|
57
|
+
argument: "old-name",
|
|
58
|
+
message: "Rename which repo?"
|
|
59
|
+
});
|
|
60
|
+
const to = yield* ReposCli.textOrAsk(newName, {
|
|
61
|
+
command: ["rename"],
|
|
62
|
+
argument: "new-name",
|
|
63
|
+
message: `Rename ${from} to?`
|
|
64
|
+
});
|
|
65
|
+
const result = yield* (yield* Repos.ReposManager).rename(cwd, from, to);
|
|
66
|
+
yield* Report.print([Report.ok(`${result.oldName}: renamed to ${result.newName} (${result.path})`, "staged — review and commit"), Doc.codeBlock(result.commitMessage)]);
|
|
67
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("rename the repo", "the manifest is unreadable, or the new name is invalid or already vendored; run `savvy repos status`")(error)), Effect.catchTag([
|
|
68
|
+
"RepoNotFoundError",
|
|
69
|
+
"GitSubmoduleError",
|
|
70
|
+
"ReposLockdownError"
|
|
71
|
+
], ReposCli.fail("rename the repo")));
|
|
63
72
|
/* v8 ignore start -- CLI registration; handler tested via runReposRename */
|
|
64
73
|
const renameCommand = Command.make("rename", {
|
|
65
74
|
oldName: oldNameArg,
|
|
66
75
|
newName: newNameArg,
|
|
67
76
|
cwd: cwdOption
|
|
68
|
-
}, ({ oldName, newName, cwd }) => runReposRename(cwd, oldName, newName)).pipe(Command.withDescription("Rename a vendored repo's manifest key and worktree; stages the change without committing"));
|
|
77
|
+
}, ({ oldName, newName, cwd }) => runReposRename(cwd, Option.getOrUndefined(oldName), Option.getOrUndefined(newName))).pipe(Command.withDescription("Rename a vendored repo's manifest key and worktree; stages the change without committing"));
|
|
69
78
|
/* v8 ignore stop */
|
|
70
79
|
|
|
71
80
|
//#endregion
|
|
@@ -1,77 +1,63 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { confirmDestructive, yesFlag } from "../../../internal/confirm.js";
|
|
3
|
+
import { ReposCli } from "../shared.js";
|
|
2
4
|
import { Argument, Command, Flag } from "effect/cli";
|
|
3
|
-
import { CliExit } from "@effected/cli";
|
|
5
|
+
import { CliExit, CliInteractive } from "@effected/cli";
|
|
4
6
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
7
|
import { Effect } from "effect";
|
|
6
8
|
|
|
7
9
|
//#region src/commands/repos/commands/restore.ts
|
|
8
|
-
/**
|
|
9
|
-
* `repos restore` command -- hard-reset one or more vendored `.repos/`
|
|
10
|
-
* submodules back to their pinned gitlink commit.
|
|
11
|
-
*
|
|
12
|
-
* @remarks
|
|
13
|
-
* A thin adapter over {@link Repos.ReposManager.restore}: explicit only --
|
|
14
|
-
* never invoked by `sync` or any other implicit path. DESTRUCTIVE to
|
|
15
|
-
* uncommitted worktree edits and untracked files in the repos it touches:
|
|
16
|
-
* each targeted repo is hard-reset to its staged gitlink commit (falling
|
|
17
|
-
* back to the committed one) and its sparse-checkout paths re-applied.
|
|
18
|
-
* With one or more `name` arguments, exactly those repos are restored, even
|
|
19
|
-
* if already clean -- an explicit ask is always honored. With no names, every
|
|
20
|
-
* DIRTY repo (per `status`) is restored and the clean ones are reported as
|
|
21
|
-
* skipped.
|
|
22
|
-
*
|
|
23
|
-
* A `ReposConfigError` with kind `"missing"` -- nothing vendored yet -- is
|
|
24
|
-
* the common, friendly case and always exits 0. A `ReposConfigError` with
|
|
25
|
-
* kind `"invalid"` means the manifest exists but is corrupt or unreadable.
|
|
26
|
-
* `RepoNotFoundError` means an explicitly named repo isn't in the manifest --
|
|
27
|
-
* a real failure, since restoring a name that was never vendored is almost
|
|
28
|
-
* always a typo the caller should see. `GitSubmoduleError` means the
|
|
29
|
-
* underlying git command failed (including the case where a repo has
|
|
30
|
-
* neither a staged nor a committed gitlink commit to restore to), and
|
|
31
|
-
* `ReposLockdownError` means the OS-permission lockdown pass on a vendored
|
|
32
|
-
* tree failed -- all four are real failures, logged and reported via a
|
|
33
|
-
* non-zero exit code.
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* ```bash
|
|
37
|
-
* savvy repos restore my-repo
|
|
38
|
-
* savvy repos restore my-repo other-repo
|
|
39
|
-
* savvy repos restore
|
|
40
|
-
* ```
|
|
41
|
-
*
|
|
42
|
-
* @internal
|
|
43
|
-
*/
|
|
44
10
|
/* v8 ignore start -- CLI option/arg definitions */
|
|
45
11
|
const namesArg = Argument.String("name").pipe(Argument.variadic());
|
|
46
12
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to restore within"), Flag.withDefault("."));
|
|
47
|
-
|
|
13
|
+
/** Which repos to restore: the names given, or (at a terminal, with none given) the dirty ones a person picks. */
|
|
14
|
+
const choose = (cwd, names) => Effect.gen(function* () {
|
|
15
|
+
if (names.length > 0 || !(yield* CliInteractive)) return {
|
|
16
|
+
names,
|
|
17
|
+
asked: false
|
|
18
|
+
};
|
|
19
|
+
const dirty = (yield* ReposCli.vendored(cwd)).filter((repo) => repo.dirty).map((repo) => repo.name);
|
|
20
|
+
if (dirty.length === 0) return {
|
|
21
|
+
names,
|
|
22
|
+
asked: false
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
names: yield* ReposCli.pickDirty(dirty),
|
|
26
|
+
asked: true
|
|
27
|
+
};
|
|
28
|
+
});
|
|
48
29
|
/**
|
|
49
30
|
* Restore handler; exported for tests.
|
|
50
31
|
*
|
|
51
32
|
* @internal
|
|
52
33
|
*/
|
|
53
|
-
const runReposRestore = (cwd, names) => Effect.gen(function* () {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
34
|
+
const runReposRestore = (cwd, names, options = {}) => Effect.gen(function* () {
|
|
35
|
+
const chosen = yield* choose(cwd, names);
|
|
36
|
+
if (chosen.asked && chosen.names.length === 0) return yield* Report.print([Report.skip("nothing selected — left unchanged")]);
|
|
37
|
+
const targets = chosen.names;
|
|
38
|
+
if (targets.length > 0) {
|
|
39
|
+
if (!(yield* confirmDestructive({
|
|
40
|
+
message: `Hard-reset ${targets.join(", ")}? Uncommitted edits and untracked files there are lost.`,
|
|
41
|
+
yes: options.yes === true
|
|
42
|
+
}))) return yield* Report.print([Report.skip("restore cancelled — nothing changed")]);
|
|
43
|
+
}
|
|
44
|
+
const result = yield* (yield* Repos.ReposManager).restore(cwd, targets.length > 0 ? targets : void 0);
|
|
45
|
+
const blocks = [...result.restored.map((entry) => Report.ok(`${entry.name}: restored to ${entry.commit}`)), ...result.skippedClean.map((name) => Report.skip(`${name}: clean — skipped`))];
|
|
46
|
+
for (const name of result.stillDirty) blocks.push(Report.fail(`${name}: reset ran but the worktree is STILL dirty; run \`savvy repos status --drift\``));
|
|
58
47
|
if (result.stillDirty.length > 0) yield* CliExit.set(1);
|
|
59
|
-
if (result.restored.length === 0 && result.skippedClean.length === 0)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
67
|
-
}), Effect.catchTag("ReposLockdownError", (error) => {
|
|
68
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
69
|
-
}));
|
|
48
|
+
if (result.restored.length === 0 && result.skippedClean.length === 0) blocks.push(Report.ok("nothing to restore"));
|
|
49
|
+
yield* Report.print(blocks);
|
|
50
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("restore the vendored repos")(error)), Effect.catchTag([
|
|
51
|
+
"RepoNotFoundError",
|
|
52
|
+
"GitSubmoduleError",
|
|
53
|
+
"ReposLockdownError"
|
|
54
|
+
], ReposCli.fail("restore the vendored repos")));
|
|
70
55
|
/* v8 ignore start -- CLI registration; handler tested via runReposRestore */
|
|
71
56
|
const restoreCommand = Command.make("restore", {
|
|
72
57
|
names: namesArg,
|
|
58
|
+
yes: yesFlag,
|
|
73
59
|
cwd: cwdOption
|
|
74
|
-
}, ({ names, cwd }) => runReposRestore(cwd, names)).pipe(Command.withDescription("Hard-reset vendored repos to their pinned commit; DESTRUCTIVE to uncommitted worktree edits. Given no names, restores every dirty repo"));
|
|
60
|
+
}, ({ names, yes, cwd }) => runReposRestore(cwd, names, { yes })).pipe(Command.withDescription("Hard-reset vendored repos to their pinned commit; DESTRUCTIVE to uncommitted worktree edits. Given no names, restores every dirty repo (asks first at a terminal)"));
|
|
75
61
|
/* v8 ignore stop */
|
|
76
62
|
|
|
77
63
|
//#endregion
|
|
@@ -1,41 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Command, Flag } from "effect/cli";
|
|
3
|
-
import { CliExit } from "@effected/cli";
|
|
4
|
+
import { CliExit, Doc, Status } from "@effected/cli";
|
|
4
5
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
6
|
import { Console, Effect } from "effect";
|
|
6
7
|
|
|
7
8
|
//#region src/commands/repos/commands/status.ts
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
9
|
+
/** The human status report: a table of repos, and one of drifts under `--drift`. */
|
|
10
|
+
var ReposStatusView = class {
|
|
11
|
+
constructor() {}
|
|
12
|
+
/** One row per repo: its name, pinned ref, and a status cell naming every flag it carries. */
|
|
13
|
+
static repos = (repos) => Doc.table([
|
|
14
|
+
{ header: "name" },
|
|
15
|
+
{ header: "ref" },
|
|
16
|
+
{ header: "state" }
|
|
17
|
+
], repos.map((repo) => {
|
|
18
|
+
const flags = [
|
|
19
|
+
repo.present ? void 0 : "missing",
|
|
20
|
+
repo.dirty ? "dirty" : void 0,
|
|
21
|
+
repo.staleNoteIds.length > 0 ? `${repo.staleNoteIds.length} stale notes` : void 0
|
|
22
|
+
].filter((f) => f !== void 0);
|
|
23
|
+
const state = flags.length > 0 ? [Doc.status(Status.core, "warning"), ` ${flags.join(", ")}`] : [Doc.status(Status.core, "success"), " clean"];
|
|
24
|
+
return [
|
|
25
|
+
repo.name,
|
|
26
|
+
repo.ref,
|
|
27
|
+
state
|
|
28
|
+
];
|
|
29
|
+
}));
|
|
30
|
+
/** One row per drift: the repo, the drift kind, and what disagrees. */
|
|
31
|
+
static drifts = (drifts) => Doc.table([
|
|
32
|
+
{ header: "name" },
|
|
33
|
+
{ header: "kind" },
|
|
34
|
+
{ header: "detail" }
|
|
35
|
+
], drifts.map((item) => [
|
|
36
|
+
[Doc.status(Status.core, "failure"), ` ${item.name}`],
|
|
37
|
+
item.kind,
|
|
38
|
+
item.detail
|
|
39
|
+
]));
|
|
40
|
+
};
|
|
39
41
|
/* v8 ignore start -- CLI option definitions */
|
|
40
42
|
const jsonOption = Flag.Boolean("json").pipe(Flag.withDescription("Emit the structured drift report as JSON"), Flag.withDefault(false));
|
|
41
43
|
const driftOption = Flag.Boolean("drift").pipe(Flag.withDescription("Also reconcile the manifest, .gitmodules, worktree, and git submodule status"), Flag.withDefault(false));
|
|
@@ -62,32 +64,26 @@ const runReposStatus = (cwd, json, drift = false) => Effect.gen(function* () {
|
|
|
62
64
|
yield* Console.log(JSON.stringify(payload, null, 2));
|
|
63
65
|
return;
|
|
64
66
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
repo.dirty ? "dirty" : void 0,
|
|
69
|
-
repo.staleNoteIds.length > 0 ? `${repo.staleNoteIds.length} stale notes` : void 0
|
|
70
|
-
].filter((f) => f !== void 0);
|
|
71
|
-
yield* flags.length > 0 ? Output.warn(`${repo.name} @ ${repo.ref} [${flags.join(", ")}]`) : Output.ok(`${repo.name} @ ${repo.ref}`);
|
|
72
|
-
}
|
|
73
|
-
if (driftReport !== void 0) for (const item of driftReport.drifts) yield* Output.fail(`${item.name}: ${item.kind} — ${item.detail}`);
|
|
67
|
+
const blocks = [report.repos.length > 0 ? ReposStatusView.repos(report.repos) : Report.skip("the manifest lists no repos")];
|
|
68
|
+
if (driftReport !== void 0 && driftReport.drifts.length > 0) blocks.push(Report.line(""), Report.heading("drift"), ReposStatusView.drifts(driftReport.drifts));
|
|
69
|
+
yield* Report.print(blocks);
|
|
74
70
|
}).pipe(Effect.catchTag("ReposConfigError", (error) => {
|
|
75
71
|
if (error.kind === "missing") {
|
|
76
72
|
if (json) return Console.log(JSON.stringify({
|
|
77
73
|
repos: [],
|
|
78
74
|
clean: true
|
|
79
75
|
}, null, 2));
|
|
80
|
-
return
|
|
76
|
+
return Report.print([Report.skip("no .repos/config.json — nothing vendored")]);
|
|
81
77
|
}
|
|
82
|
-
|
|
78
|
+
if (json) return CliExit.set(1).pipe(Effect.andThen(Console.log(JSON.stringify({
|
|
83
79
|
error: error.message,
|
|
84
80
|
clean: false
|
|
85
|
-
}, null, 2))
|
|
86
|
-
return
|
|
81
|
+
}, null, 2))), Effect.andThen(Effect.logError(error.message)));
|
|
82
|
+
return ReposCli.fail("read the repos manifest")(error);
|
|
87
83
|
}), Effect.catchTag("GitSubmoduleError", (error) => json ? CliExit.set(1).pipe(Effect.andThen(Console.log(JSON.stringify({
|
|
88
84
|
error: error.message,
|
|
89
85
|
clean: false
|
|
90
|
-
}, null, 2))), Effect.andThen(Effect.logError(error.message))) :
|
|
86
|
+
}, null, 2))), Effect.andThen(Effect.logError(error.message))) : ReposCli.fail("check the vendored repos")(error)));
|
|
91
87
|
/* v8 ignore start -- CLI registration; handler tested via runReposStatus */
|
|
92
88
|
const statusCommand = Command.make("status", {
|
|
93
89
|
json: jsonOption,
|
|
@@ -1,34 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Report } from "../../../internal/report.js";
|
|
2
|
+
import { ReposCli } from "../shared.js";
|
|
2
3
|
import { Command, Flag } from "effect/cli";
|
|
3
|
-
import { CliExit } from "@effected/cli";
|
|
4
4
|
import { Repos } from "@savvy-web/silk-effects";
|
|
5
5
|
import { Effect } from "effect";
|
|
6
6
|
|
|
7
7
|
//#region src/commands/repos/commands/sync.ts
|
|
8
|
-
/**
|
|
9
|
-
* `repos sync` command -- reconcile vendored `.repos/` submodules with the manifest.
|
|
10
|
-
*
|
|
11
|
-
* @remarks
|
|
12
|
-
* A thin adapter over {@link Repos.ReposManager.sync}: initializes missing
|
|
13
|
-
* submodules, re-applies sparse-checkout patterns, clears stale git locks
|
|
14
|
-
* left behind by an interrupted fetch, reconciles a submodule's URL when it
|
|
15
|
-
* drifts from the manifest, and registers an orphan manifest entry (no
|
|
16
|
-
* gitlink at all) from scratch. Sync is idempotent repair, so
|
|
17
|
-
* a `ReposConfigError` with kind `"missing"` -- the common, friendly case
|
|
18
|
-
* (nothing to sync yet) -- always exits 0. A `ReposConfigError` with kind
|
|
19
|
-
* `"invalid"` means the manifest exists but is corrupt or unreadable,
|
|
20
|
-
* `GitSubmoduleError` means the underlying git command failed, and
|
|
21
|
-
* `ReposLockdownError` means the OS-permission lockdown pass on a vendored
|
|
22
|
-
* tree failed -- all three are real failures, logged and reported via a
|
|
23
|
-
* non-zero exit code.
|
|
24
|
-
*
|
|
25
|
-
* @example
|
|
26
|
-
* ```bash
|
|
27
|
-
* savvy repos sync
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* @internal
|
|
31
|
-
*/
|
|
32
8
|
/* v8 ignore start -- CLI option definitions */
|
|
33
9
|
const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to sync"), Flag.withDefault("."));
|
|
34
10
|
/* v8 ignore stop */
|
|
@@ -39,20 +15,15 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
|
|
|
39
15
|
*/
|
|
40
16
|
const runReposSync = (cwd) => Effect.gen(function* () {
|
|
41
17
|
const report = yield* (yield* Repos.ReposManager).sync(cwd);
|
|
42
|
-
|
|
43
|
-
for (const name of report.
|
|
44
|
-
for (const name of report.
|
|
45
|
-
for (const name of report.
|
|
46
|
-
for (const name of report.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}), Effect.catchTag("GitSubmoduleError", (error) => {
|
|
52
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
53
|
-
}), Effect.catchTag("ReposLockdownError", (error) => {
|
|
54
|
-
return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
|
|
55
|
-
}));
|
|
18
|
+
const blocks = [];
|
|
19
|
+
for (const name of report.clearedLocks) blocks.push(Report.ok(`${name}: cleared stale lock`));
|
|
20
|
+
for (const name of report.initialized) blocks.push(Report.ok(`${name}: initialized`));
|
|
21
|
+
for (const name of report.sparseApplied) blocks.push(Report.ok(`${name}: sparse-checkout applied`));
|
|
22
|
+
for (const name of report.urlSynced) blocks.push(Report.ok(`${name}: url reconciled`));
|
|
23
|
+
for (const name of report.registered) blocks.push(Report.ok(`${name}: registered`));
|
|
24
|
+
if (report.initialized.length === 0 && report.sparseApplied.length === 0 && report.clearedLocks.length === 0 && report.urlSynced.length === 0 && report.registered.length === 0) blocks.push(Report.ok("all vendored repos up to date"));
|
|
25
|
+
yield* Report.print(blocks);
|
|
26
|
+
}).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? ReposCli.nothingVendored : ReposCli.fail("sync the vendored repos")(error)), Effect.catchTag(["GitSubmoduleError", "ReposLockdownError"], ReposCli.fail("sync the vendored repos")));
|
|
56
27
|
/* v8 ignore start -- CLI registration; handler tested via runReposSync */
|
|
57
28
|
const syncCommand = Command.make("sync", { cwd: cwdOption }, ({ cwd }) => runReposSync(cwd)).pipe(Command.withDescription("Reconcile vendored submodules with the manifest: init missing, apply sparse, clear locks"));
|
|
58
29
|
/* v8 ignore stop */
|
package/commands/repos/index.js
CHANGED
|
@@ -39,16 +39,13 @@ const _reposCommand = Command.make("repos").pipe(Command.withSubcommands([
|
|
|
39
39
|
* discharges via `AppLive`. `Repos.ReposDrift` joins the union because
|
|
40
40
|
* `status --drift` runs `Repos.ReposDrift.check` after the status check.
|
|
41
41
|
*
|
|
42
|
-
* The error channel is `Repos
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* `
|
|
48
|
-
*
|
|
49
|
-
* `ReposConfigError`; `GitSubmoduleError` from either `ReposManager.status`
|
|
50
|
-
* or (under `--drift`) `Repos.ReposDrift.check` is the sole channel that
|
|
51
|
-
* escapes uncaught, so it is the only member left in this union.
|
|
42
|
+
* The error channel is what a handler fails with, never a raw `Repos` error:
|
|
43
|
+
* every handler maps each error its `ReposManager` method (or the picker's
|
|
44
|
+
* `status` read) can produce to a self-rendering `CommandError` (exit 1),
|
|
45
|
+
* except the friendly missing-manifest case and `status --json`'s JSON error
|
|
46
|
+
* document. A repo name or other positional left off is a
|
|
47
|
+
* `CliError.ShowHelp` (exit 64) when no one can be asked, and backing out of
|
|
48
|
+
* a picker or a confirm is the kit's `Cancelled` (exit 130).
|
|
52
49
|
*/
|
|
53
50
|
const reposCommand = _reposCommand;
|
|
54
51
|
|