@fluid-app/fluid-cli-mist 0.1.81 → 0.1.83
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/dist/index.mjs +282 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -2,12 +2,15 @@ import { Command, InvalidArgumentError, Option } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { getAuthToken, getConfigDir, gitSyncActorFromMe, gitSyncCommitSubject, parseNameStatusZ, resolveGitSyncActor, summarizeChanges } from "@fluid-app/fluid-cli";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import path, { join, resolve } from "node:path";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import { execa } from "execa";
|
|
9
9
|
import ora from "ora";
|
|
10
10
|
import prompts from "prompts";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { promisify } from "node:util";
|
|
11
14
|
import open from "open";
|
|
12
15
|
//#region src/api/client.ts
|
|
13
16
|
/**
|
|
@@ -547,6 +550,18 @@ async function ensureGitAvailable() {
|
|
|
547
550
|
}
|
|
548
551
|
}
|
|
549
552
|
/**
|
|
553
|
+
* Abort a transfer that has moved under 1 KB/s for a full minute. Without
|
|
554
|
+
* this, git on a dropped or captive-portal Wi-Fi connection waits forever
|
|
555
|
+
* and the caller (Fluid Mist's clone/sync) shows a spinner that never ends;
|
|
556
|
+
* with it, the command fails and can be retried.
|
|
557
|
+
*/
|
|
558
|
+
const GIT_STALL_GUARD_ARGS = [
|
|
559
|
+
"-c",
|
|
560
|
+
"http.lowSpeedLimit=1000",
|
|
561
|
+
"-c",
|
|
562
|
+
"http.lowSpeedTime=60"
|
|
563
|
+
];
|
|
564
|
+
/**
|
|
550
565
|
* `git clone <cleanUrl> <targetDir>`. The original remoteUrl carries a
|
|
551
566
|
* token; gitAuthSpawn strips it from the URL and re-injects it via
|
|
552
567
|
* GIT_ASKPASS so it never appears in argv. `credential.helper=` stops
|
|
@@ -557,6 +572,7 @@ async function gitClone(remoteUrl, targetDir) {
|
|
|
557
572
|
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
558
573
|
try {
|
|
559
574
|
await execa("git", [
|
|
575
|
+
...GIT_STALL_GUARD_ARGS,
|
|
560
576
|
"-c",
|
|
561
577
|
"credential.helper=",
|
|
562
578
|
"clone",
|
|
@@ -614,6 +630,7 @@ async function gitPull(cwd, remoteUrl, branch = "main") {
|
|
|
614
630
|
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
615
631
|
try {
|
|
616
632
|
await execa("git", [
|
|
633
|
+
...GIT_STALL_GUARD_ARGS,
|
|
617
634
|
"-c",
|
|
618
635
|
"credential.helper=",
|
|
619
636
|
"pull",
|
|
@@ -636,6 +653,20 @@ async function gitPull(cwd, remoteUrl, branch = "main") {
|
|
|
636
653
|
throw new GitError(`git pull failed${e.exitCode != null ? ` (exit ${e.exitCode})` : ""}`, e.exitCode ?? 1);
|
|
637
654
|
}
|
|
638
655
|
}
|
|
656
|
+
/** True when `relativePath` is ignored by the repo at `cwd`. */
|
|
657
|
+
async function isGitIgnored(cwd, relativePath) {
|
|
658
|
+
try {
|
|
659
|
+
await execa("git", [
|
|
660
|
+
"check-ignore",
|
|
661
|
+
"--quiet",
|
|
662
|
+
"--",
|
|
663
|
+
relativePath
|
|
664
|
+
], { cwd });
|
|
665
|
+
return true;
|
|
666
|
+
} catch {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
639
670
|
/**
|
|
640
671
|
* Are there uncommitted changes (working tree or index) in this repo?
|
|
641
672
|
* `git status --porcelain` is empty when everything is clean, non-empty
|
|
@@ -660,6 +691,7 @@ async function gitFetch(cwd, remoteUrl, branch = "main") {
|
|
|
660
691
|
const { cleanUrl, env } = gitAuthSpawn(remoteUrl);
|
|
661
692
|
try {
|
|
662
693
|
await execa("git", [
|
|
694
|
+
...GIT_STALL_GUARD_ARGS,
|
|
663
695
|
"-c",
|
|
664
696
|
"credential.helper=",
|
|
665
697
|
"fetch",
|
|
@@ -740,21 +772,126 @@ async function gitStash(cwd, message) {
|
|
|
740
772
|
throw new GitError(`git stash push failed`, err.exitCode ?? 1);
|
|
741
773
|
}
|
|
742
774
|
}
|
|
775
|
+
/** Read the current HEAD sha, or null if there is no commit yet. */
|
|
776
|
+
async function getHeadSha(cwd) {
|
|
777
|
+
try {
|
|
778
|
+
const { stdout } = await execa("git", [
|
|
779
|
+
"rev-parse",
|
|
780
|
+
"--verify",
|
|
781
|
+
"HEAD"
|
|
782
|
+
], { cwd });
|
|
783
|
+
return stdout.trim() || null;
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/** Shown when a teammate's pulled change collides with local edits. */
|
|
789
|
+
const STASH_CONFLICT_MESSAGE = "Your unpublished edits conflict with a teammate's change to the same files. Nothing was published and your edits are back exactly as they were. Merge the teammate's change first (ask Mist to, or run `git pull` in a terminal), then publish again.";
|
|
743
790
|
/**
|
|
744
|
-
* Pop the most recent stash.
|
|
745
|
-
*
|
|
746
|
-
*
|
|
791
|
+
* Pop the most recent stash.
|
|
792
|
+
*
|
|
793
|
+
* Without `restoreHead`, a failed pop throws and leaves the tree as git
|
|
794
|
+
* left it. With `restoreHead` (the HEAD the stash was taken on, captured
|
|
795
|
+
* BEFORE the pull), a failed pop never leaves conflict markers behind: the
|
|
796
|
+
* tree is hard-reset to `restoreHead`, any untracked files the failed pop
|
|
797
|
+
* already wrote are removed (the stash still holds them), and the stash is
|
|
798
|
+
* popped again onto the base it was taken from — which applies cleanly.
|
|
799
|
+
* The pulled commits are dropped from HEAD (they stay on the remote), and
|
|
800
|
+
* a GitError with {@link STASH_CONFLICT_MESSAGE} is thrown so the caller
|
|
801
|
+
* aborts the publish instead of committing `<<<<<<<` markers.
|
|
747
802
|
*/
|
|
748
|
-
async function gitStashPop(cwd) {
|
|
803
|
+
async function gitStashPop(cwd, restoreHead) {
|
|
804
|
+
try {
|
|
805
|
+
await execa("git", ["stash", "pop"], { cwd });
|
|
806
|
+
return;
|
|
807
|
+
} catch (err) {
|
|
808
|
+
const missing = gitMissingError(err);
|
|
809
|
+
if (missing) throw missing;
|
|
810
|
+
if (!restoreHead) throw new GitError(`Couldn't restore your local changes after pulling — the file edits collided with a teammate's. Run \`git stash list\` and resolve manually.`, err.exitCode ?? 1);
|
|
811
|
+
}
|
|
812
|
+
await restoreStashOnto(cwd, restoreHead);
|
|
813
|
+
throw new GitError(STASH_CONFLICT_MESSAGE, 1);
|
|
814
|
+
}
|
|
815
|
+
async function restoreStashOnto(cwd, head) {
|
|
749
816
|
try {
|
|
817
|
+
await execa("git", [
|
|
818
|
+
"reset",
|
|
819
|
+
"--hard",
|
|
820
|
+
"--quiet",
|
|
821
|
+
head
|
|
822
|
+
], { cwd });
|
|
823
|
+
const untracked = await execa("git", [
|
|
824
|
+
"ls-tree",
|
|
825
|
+
"-r",
|
|
826
|
+
"-z",
|
|
827
|
+
"--name-only",
|
|
828
|
+
"stash@{0}^3"
|
|
829
|
+
], {
|
|
830
|
+
cwd,
|
|
831
|
+
reject: false
|
|
832
|
+
});
|
|
833
|
+
if (untracked.exitCode === 0) {
|
|
834
|
+
const root = (await execa("git", ["rev-parse", "--show-toplevel"], { cwd })).stdout.trim();
|
|
835
|
+
for (const rel of untracked.stdout.split("\0").filter(Boolean)) rmSync(path.join(root, rel), { force: true });
|
|
836
|
+
}
|
|
750
837
|
await execa("git", ["stash", "pop"], { cwd });
|
|
751
838
|
} catch (err) {
|
|
752
839
|
const missing = gitMissingError(err);
|
|
753
840
|
if (missing) throw missing;
|
|
754
|
-
throw new GitError(
|
|
841
|
+
throw new GitError("Your edits conflict with a teammate's change and couldn't be restored automatically. Nothing was published; your edits are saved in `git stash list`.", 1);
|
|
755
842
|
}
|
|
756
843
|
}
|
|
757
844
|
/**
|
|
845
|
+
* Refuse to commit a tree that still carries merge-conflict state:
|
|
846
|
+
* unmerged index entries (`git ls-files -u`), or — checked after staging —
|
|
847
|
+
* a staged file with a line starting `<<<<<<< `. Publishing either would
|
|
848
|
+
* deploy a broken site.
|
|
849
|
+
*/
|
|
850
|
+
async function assertNoUnmergedPaths(cwd) {
|
|
851
|
+
const { stdout } = await execa("git", [
|
|
852
|
+
"ls-files",
|
|
853
|
+
"-u",
|
|
854
|
+
"-z"
|
|
855
|
+
], {
|
|
856
|
+
cwd,
|
|
857
|
+
reject: false
|
|
858
|
+
});
|
|
859
|
+
if (stdout.trim().length > 0) throw new GitError(CONFLICT_REFUSAL_MESSAGE, 1);
|
|
860
|
+
}
|
|
861
|
+
const CONFLICT_REFUSAL_MESSAGE = "Some files still have unresolved merge conflicts (lines starting `<<<<<<<`). Nothing was published. Resolve the conflicts, then publish again.";
|
|
862
|
+
async function assertNoStagedConflictMarkers(cwd) {
|
|
863
|
+
const staged = await execa("git", [
|
|
864
|
+
"diff",
|
|
865
|
+
"--cached",
|
|
866
|
+
"--name-only",
|
|
867
|
+
"--diff-filter=ACMR",
|
|
868
|
+
"-z"
|
|
869
|
+
], {
|
|
870
|
+
cwd,
|
|
871
|
+
reject: false
|
|
872
|
+
});
|
|
873
|
+
const stagedPaths = new Set(staged.stdout.split("\0").filter(Boolean));
|
|
874
|
+
if (stagedPaths.size === 0) return;
|
|
875
|
+
const hits = await execa("git", [
|
|
876
|
+
"grep",
|
|
877
|
+
"--cached",
|
|
878
|
+
"-I",
|
|
879
|
+
"-l",
|
|
880
|
+
"-z",
|
|
881
|
+
"--full-name",
|
|
882
|
+
"-E",
|
|
883
|
+
"^<<<<<<< ",
|
|
884
|
+
"--",
|
|
885
|
+
":(top)"
|
|
886
|
+
], {
|
|
887
|
+
cwd,
|
|
888
|
+
reject: false
|
|
889
|
+
});
|
|
890
|
+
if (hits.exitCode !== 0) return;
|
|
891
|
+
const conflicted = hits.stdout.split("\0").filter((p) => p && stagedPaths.has(p));
|
|
892
|
+
if (conflicted.length > 0) throw new GitError(`${CONFLICT_REFUSAL_MESSAGE} Conflicted: ${conflicted.slice(0, 5).join(", ")}`, 1);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
758
895
|
* Persist the Mist bot as the repo's LOCAL git identity
|
|
759
896
|
* (`git config --local user.{name,email}`).
|
|
760
897
|
*
|
|
@@ -898,6 +1035,7 @@ async function gitAutoCommit(cwd, message) {
|
|
|
898
1035
|
if (stdout.trim().length > 0) repoRoot = stdout.trim();
|
|
899
1036
|
} catch {}
|
|
900
1037
|
excludeMistMarkerFromGit(repoRoot);
|
|
1038
|
+
await assertNoUnmergedPaths(cwd);
|
|
901
1039
|
try {
|
|
902
1040
|
await execa("git", ["add", "-A"], { cwd });
|
|
903
1041
|
} catch (err) {
|
|
@@ -925,6 +1063,7 @@ async function gitAutoCommit(cwd, message) {
|
|
|
925
1063
|
".mist-desktop"
|
|
926
1064
|
], { cwd: repoRoot });
|
|
927
1065
|
} catch {}
|
|
1066
|
+
await assertNoStagedConflictMarkers(cwd);
|
|
928
1067
|
const subject = typeof message === "string" ? message : await message(await stagedChanges(cwd));
|
|
929
1068
|
const identityArgs = [
|
|
930
1069
|
"-c",
|
|
@@ -3416,6 +3555,65 @@ async function createGitCredential(client, slug) {
|
|
|
3416
3555
|
return client.post(`/api/v202604/mists/${id}/git_credential`);
|
|
3417
3556
|
}
|
|
3418
3557
|
//#endregion
|
|
3558
|
+
//#region src/lib/env-placeholders.ts
|
|
3559
|
+
/**
|
|
3560
|
+
* After `fluid mist clone`, list the production env keys the checkout has no
|
|
3561
|
+
* local value for, as placeholders in `.env.local`.
|
|
3562
|
+
*
|
|
3563
|
+
* A fresh clone has no env file, so the app fails at the first missing key
|
|
3564
|
+
* with an error that rarely names it. Writing the keys (never the values —
|
|
3565
|
+
* Vercel returns ciphertext, and secrets don't belong on disk unasked) tells
|
|
3566
|
+
* the user exactly what to fill in. Mist-managed keys (DATABASE_URL,
|
|
3567
|
+
* FLUID_*) are left out: local dev provides its own.
|
|
3568
|
+
*
|
|
3569
|
+
* The placeholders are commented out on purpose. An uncommented `KEY=`
|
|
3570
|
+
* defines the variable as an empty string, which `process.env.KEY ?? default`
|
|
3571
|
+
* and Next.js's NEXT_PUBLIC_ inlining treat differently from unset — so an
|
|
3572
|
+
* empty placeholder could break an app that works with the key absent.
|
|
3573
|
+
*/
|
|
3574
|
+
/** Env files Next.js and Vite load in local development. */
|
|
3575
|
+
const LOCAL_ENV_FILES = [
|
|
3576
|
+
".env",
|
|
3577
|
+
".env.local",
|
|
3578
|
+
".env.development",
|
|
3579
|
+
".env.development.local"
|
|
3580
|
+
];
|
|
3581
|
+
const ENV_ASSIGNMENT$1 = /^\s*#?\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
3582
|
+
/** Keys assigned (or already listed as a placeholder) in the local env files. */
|
|
3583
|
+
function localEnvKeys(dir) {
|
|
3584
|
+
const keys = /* @__PURE__ */ new Set();
|
|
3585
|
+
for (const name of LOCAL_ENV_FILES) {
|
|
3586
|
+
const file = join(dir, name);
|
|
3587
|
+
if (!existsSync(file)) continue;
|
|
3588
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
3589
|
+
const key = ENV_ASSIGNMENT$1.exec(line)?.[1];
|
|
3590
|
+
if (key) keys.add(key);
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
3593
|
+
return keys;
|
|
3594
|
+
}
|
|
3595
|
+
/** Production keys a user set that the checkout doesn't define locally. */
|
|
3596
|
+
function missingProductionKeys(envVars, localKeys) {
|
|
3597
|
+
const missing = /* @__PURE__ */ new Set();
|
|
3598
|
+
for (const v of envVars) {
|
|
3599
|
+
if (v.managed_by === "mist") continue;
|
|
3600
|
+
if (!v.targets.includes("production")) continue;
|
|
3601
|
+
if (localKeys.has(v.key)) continue;
|
|
3602
|
+
missing.add(v.key);
|
|
3603
|
+
}
|
|
3604
|
+
return [...missing].sort();
|
|
3605
|
+
}
|
|
3606
|
+
/** The block appended to `.env.local`, or "" when nothing is missing. */
|
|
3607
|
+
function renderEnvPlaceholders(keys) {
|
|
3608
|
+
if (keys.length === 0) return "";
|
|
3609
|
+
return [
|
|
3610
|
+
"# Set in production but not here. Uncomment and fill in the ones this",
|
|
3611
|
+
"# app needs locally (values are never copied — see `fluid mist env pull`).",
|
|
3612
|
+
...keys.map((key) => `# ${key}=`),
|
|
3613
|
+
""
|
|
3614
|
+
].join("\n");
|
|
3615
|
+
}
|
|
3616
|
+
//#endregion
|
|
3419
3617
|
//#region src/commands/clone.ts
|
|
3420
3618
|
/**
|
|
3421
3619
|
* `fluid mist clone <slug>` — mints a fresh git credential and clones
|
|
@@ -3458,10 +3656,33 @@ const cloneCommand = new Command("clone").description("Clone the mist's GitHub r
|
|
|
3458
3656
|
}
|
|
3459
3657
|
writeMistFile(targetDir, mist);
|
|
3460
3658
|
spinner.succeed(`Cloned into ${chalk.cyan(targetDir)}`);
|
|
3659
|
+
await writeEnvPlaceholders(client, mist.id || mist.slug, targetDir);
|
|
3461
3660
|
const displayPath = opts.dir ?? join(".", dirName);
|
|
3462
3661
|
console.log(chalk.dim(` cd ${quotePathIfNeeded(displayPath)}`));
|
|
3463
3662
|
if (mist.public_url) console.log(chalk.dim(` ${chalk.cyan(mist.public_url)}`));
|
|
3464
3663
|
}));
|
|
3664
|
+
/**
|
|
3665
|
+
* List production env keys this checkout has no local value for as
|
|
3666
|
+
* commented placeholders in `.env.local` — keys only, never values. Only
|
|
3667
|
+
* when `.env.local` is git-ignored, so the file can't be committed.
|
|
3668
|
+
* Best-effort: a clone never fails over it.
|
|
3669
|
+
*/
|
|
3670
|
+
async function writeEnvPlaceholders(client, slug, targetDir) {
|
|
3671
|
+
try {
|
|
3672
|
+
if (!await isGitIgnored(targetDir, ".env.local")) return 0;
|
|
3673
|
+
const { env_vars } = await listEnvVars(client, slug);
|
|
3674
|
+
const keys = missingProductionKeys(env_vars ?? [], localEnvKeys(targetDir));
|
|
3675
|
+
const block = renderEnvPlaceholders(keys);
|
|
3676
|
+
if (!block) return 0;
|
|
3677
|
+
const target = join(targetDir, ".env.local");
|
|
3678
|
+
const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
3679
|
+
appendFileSync(target, `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${existing ? "\n" : ""}${block}`, "utf8");
|
|
3680
|
+
console.log(chalk.dim(` Listed ${keys.length} production env key(s) in .env.local to fill in: ${keys.join(", ")}`));
|
|
3681
|
+
return keys.length;
|
|
3682
|
+
} catch {
|
|
3683
|
+
return 0;
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3465
3686
|
//#endregion
|
|
3466
3687
|
//#region src/api/deployments.ts
|
|
3467
3688
|
async function listDeployments(client, slug, limit) {
|
|
@@ -3488,6 +3709,57 @@ async function fetchCurrentUser(client) {
|
|
|
3488
3709
|
};
|
|
3489
3710
|
}
|
|
3490
3711
|
//#endregion
|
|
3712
|
+
//#region src/lib/rollback-subject.ts
|
|
3713
|
+
const execFileAsync = promisify(execFile);
|
|
3714
|
+
/** The marker Mist Desktop writes when it rolls a checkout back. */
|
|
3715
|
+
const ROLLBACK_MARKER = ".mist-rollback.json";
|
|
3716
|
+
/**
|
|
3717
|
+
* "Roll back to 492c088 — Document reviews env vars" for a push that carries
|
|
3718
|
+
* a fresh rollback marker, so History names what was restored instead of
|
|
3719
|
+
* "Delete 23 files, update 8, add 1". Null when there is no readable marker;
|
|
3720
|
+
* the caller then describes the diff as before.
|
|
3721
|
+
*/
|
|
3722
|
+
async function rollbackActionFromMarker(cwd) {
|
|
3723
|
+
let restored;
|
|
3724
|
+
try {
|
|
3725
|
+
const raw = await readFile(path.join(cwd, ROLLBACK_MARKER), "utf8");
|
|
3726
|
+
restored = JSON.parse(raw).restored_commit;
|
|
3727
|
+
} catch {
|
|
3728
|
+
return null;
|
|
3729
|
+
}
|
|
3730
|
+
if (typeof restored !== "string" || !/^[0-9a-f]{7,40}$/i.test(restored)) return null;
|
|
3731
|
+
let subject = "";
|
|
3732
|
+
try {
|
|
3733
|
+
const { stdout } = await execFileAsync("git", [
|
|
3734
|
+
"log",
|
|
3735
|
+
"-1",
|
|
3736
|
+
"--format=%s",
|
|
3737
|
+
restored
|
|
3738
|
+
], {
|
|
3739
|
+
cwd,
|
|
3740
|
+
windowsHide: true
|
|
3741
|
+
});
|
|
3742
|
+
subject = stdout.trim();
|
|
3743
|
+
} catch {}
|
|
3744
|
+
return rollbackAction(restored, subject);
|
|
3745
|
+
}
|
|
3746
|
+
/** Pure part of {@link rollbackActionFromMarker}, for tests. */
|
|
3747
|
+
function rollbackAction(sha, subject) {
|
|
3748
|
+
const short = sha.slice(0, 7);
|
|
3749
|
+
const title = subjectAction(subject);
|
|
3750
|
+
const base = `Roll back to ${short}`;
|
|
3751
|
+
if (!title) return base;
|
|
3752
|
+
const full = `${base} — ${title}`;
|
|
3753
|
+
return full.length > 72 ? `${full.slice(0, 71).trimEnd()}…` : full;
|
|
3754
|
+
}
|
|
3755
|
+
/** A Git Sync stamp: "Name: action · YYYY-MM-DD HH:MM:SS (public id)". */
|
|
3756
|
+
const STAMPED_SUBJECT_RE = /^.+?: (.+) · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: \([^)]*\))?$/;
|
|
3757
|
+
/** A stamped subject contributes only its action; others pass through. */
|
|
3758
|
+
function subjectAction(subject) {
|
|
3759
|
+
const trimmed = subject.trim();
|
|
3760
|
+
return trimmed.match(STAMPED_SUBJECT_RE)?.[1]?.trim() ?? trimmed;
|
|
3761
|
+
}
|
|
3762
|
+
//#endregion
|
|
3491
3763
|
//#region src/commands/push.ts
|
|
3492
3764
|
/**
|
|
3493
3765
|
* `fluid mist push [--watch]` — mints a credential, pushes to origin, and
|
|
@@ -3526,11 +3798,12 @@ const pushCommand = new Command("push").description("Commit working-tree changes
|
|
|
3526
3798
|
if (behind > 0 && ahead === 0) {
|
|
3527
3799
|
const wasDirty = await hasUncommittedChanges(cwd);
|
|
3528
3800
|
driftSpinner.text = `Pulling ${behind} commit${behind === 1 ? "" : "s"} from origin/main…`;
|
|
3801
|
+
const preHead = await getHeadSha(cwd);
|
|
3529
3802
|
if (wasDirty) await gitStash(cwd, "mist-push: pre-pull stash");
|
|
3530
3803
|
try {
|
|
3531
3804
|
await gitMergeFastForward(cwd);
|
|
3532
3805
|
} finally {
|
|
3533
|
-
if (wasDirty) await gitStashPop(cwd);
|
|
3806
|
+
if (wasDirty) await gitStashPop(cwd, preHead);
|
|
3534
3807
|
}
|
|
3535
3808
|
driftSpinner.succeed(`Pulled ${behind} commit${behind === 1 ? "" : "s"} from origin/main`);
|
|
3536
3809
|
} else if (behind > 0 && ahead > 0) {
|
|
@@ -3546,6 +3819,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
|
|
|
3546
3819
|
if (opts.commit !== false) {
|
|
3547
3820
|
if (await hasUncommittedChanges(cwd)) {
|
|
3548
3821
|
const explicit = opts.message?.trim();
|
|
3822
|
+
const rollbackAction = explicit ? null : await rollbackActionFromMarker(cwd);
|
|
3549
3823
|
const actor = await resolveGitSyncActor(async () => {
|
|
3550
3824
|
try {
|
|
3551
3825
|
return gitSyncActorFromMe(await withTimeout(fetchCurrentUser(client), 5e3));
|
|
@@ -3559,7 +3833,7 @@ const pushCommand = new Command("push").description("Commit working-tree changes
|
|
|
3559
3833
|
const commitSpinner = ora("Committing changes…").start();
|
|
3560
3834
|
try {
|
|
3561
3835
|
await gitAutoCommit(cwd, (changes) => {
|
|
3562
|
-
subject = gitSyncCommitSubject(explicit || summarizeChanges(changes) || FALLBACK_ACTION, actor);
|
|
3836
|
+
subject = gitSyncCommitSubject(explicit || (rollbackAction && changes.some((change) => change.path === ".mist-rollback.json") ? rollbackAction : null) || summarizeChanges(changes) || FALLBACK_ACTION, actor);
|
|
3563
3837
|
return subject;
|
|
3564
3838
|
});
|
|
3565
3839
|
commitSpinner.succeed(subject ? `Committed — ${subject}` : "Committed");
|