laneyard 0.6.1 → 0.8.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 +13 -497
- package/dist/src/cli/adopt.js +24 -8
- package/dist/src/cli/remove.js +52 -34
- package/dist/src/cli/reset.js +2 -2
- package/dist/src/cli/setup.js +85 -40
- package/dist/src/cli/uninstall.js +5 -21
- package/dist/src/config/env-file-setting.js +57 -0
- package/dist/src/config/resolve.js +8 -5
- package/dist/src/config/schema.js +29 -0
- package/dist/src/db/credentials.js +18 -54
- package/dist/src/db/migrate-global-scope.js +75 -0
- package/dist/src/db/open.js +21 -0
- package/dist/src/db/schema.sql +10 -5
- package/dist/src/db/secrets.js +44 -65
- package/dist/src/fastfile/adoption.js +19 -4
- package/dist/src/git/workspace.js +28 -0
- package/dist/src/heuristics/readiness.js +80 -135
- package/dist/src/main.js +31 -2
- package/dist/src/runner/env-file.js +144 -0
- package/dist/src/runner/orchestrate.js +39 -5
- package/dist/src/secrets/vault.js +75 -40
- package/dist/src/server/app.js +4 -0
- package/dist/src/server/routes/credentials.js +0 -13
- package/dist/src/server/routes/fastfile.js +1 -1
- package/dist/src/server/routes/projects.js +59 -16
- package/dist/src/server/routes/readiness.js +2 -3
- package/dist/src/server/routes/secrets.js +111 -39
- package/dist/src/sidecar/fastlane-dir.js +18 -8
- package/dist/src/sidecar/lanes.js +1 -1
- package/dist/src/sidecar/uses.js +1 -1
- package/dist/web/assets/{Editor-CMa4-4N3.js → Editor-BK-ZRKlI.js} +1 -1
- package/dist/web/assets/index-DnUdqjT4.js +62 -0
- package/dist/web/assets/{index-DsjxZtsO.css → index-r3IyRuO4.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B8lAQPEM.js +0 -62
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** The two tables that carried a scope, and the column that identified a row in each. */
|
|
2
|
+
const TABLES = [
|
|
3
|
+
{ table: "secret", column: "key", what: "secret" },
|
|
4
|
+
{ table: "credential", column: "kind", what: "signing block" },
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* Gives every row that belonged to everything to each project that read it.
|
|
8
|
+
*
|
|
9
|
+
* The vault used to have two scopes: a row stored under the empty slug applied
|
|
10
|
+
* to every project, and a row under a real slug shadowed it. That is gone — a
|
|
11
|
+
* secret and a signing block now belong to exactly one project — and this is
|
|
12
|
+
* what happens to the rows written before it went.
|
|
13
|
+
*
|
|
14
|
+
* **Copied, never dropped.** A global secret was read by every project, so
|
|
15
|
+
* writing it into every project preserves precisely the behaviour it had:
|
|
16
|
+
* nothing that built yesterday stops building today. The alternative — deleting
|
|
17
|
+
* rows the user never asked to delete, on an upgrade — would break working
|
|
18
|
+
* projects to tidy a table.
|
|
19
|
+
*
|
|
20
|
+
* **A project that overrode a global one keeps its own value.** The insert
|
|
21
|
+
* takes no action on conflict, which is the same precedence the merged read
|
|
22
|
+
* applied, made permanent rather than recomputed on every query.
|
|
23
|
+
*
|
|
24
|
+
* The cost is real and is the point of the report: someone who stored one App
|
|
25
|
+
* Store Connect key now has five, and rotating it means replacing five. They
|
|
26
|
+
* have to be told, or they will find out from a build that uploads with a key
|
|
27
|
+
* they thought they had replaced.
|
|
28
|
+
*
|
|
29
|
+
* With no projects configured there is nowhere to copy to, and the row is
|
|
30
|
+
* deleted rather than left: every query names a slug now, so the empty one is
|
|
31
|
+
* unreachable — not shared, just invisible and forever unread.
|
|
32
|
+
*/
|
|
33
|
+
export function migrateGlobalScope(db, allSlugs) {
|
|
34
|
+
const report = { copied: [], dropped: [] };
|
|
35
|
+
// The empty slug is the sentinel this function is deleting. A configuration
|
|
36
|
+
// cannot produce one — `slugSchema` refuses it — but if one ever arrived, the
|
|
37
|
+
// copy would land on the row about to be deleted and the data would go with
|
|
38
|
+
// it, silently. That is the one input worth refusing by hand.
|
|
39
|
+
const slugs = allSlugs.filter((slug) => slug !== "");
|
|
40
|
+
const run = db.transaction(() => {
|
|
41
|
+
for (const { table, column, what } of TABLES) {
|
|
42
|
+
const names = db.prepare(`SELECT ${column} AS name FROM ${table} WHERE project_slug = '' ORDER BY ${column}`).all().map((row) => row.name);
|
|
43
|
+
if (names.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
// `SELECT ?, <rest>` rather than a read-then-write: it keeps every column
|
|
46
|
+
// the table has without naming them here, so a column added later travels
|
|
47
|
+
// with the copy instead of being silently dropped by a migration nobody
|
|
48
|
+
// thought to update.
|
|
49
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
|
|
50
|
+
const rest = columns.filter((c) => c !== "project_slug");
|
|
51
|
+
const copy = db.prepare(`INSERT INTO ${table} (project_slug, ${rest.join(", ")})
|
|
52
|
+
SELECT ?, ${rest.join(", ")} FROM ${table} WHERE project_slug = '' AND ${column} = ?
|
|
53
|
+
ON CONFLICT DO NOTHING`);
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const wrote = [];
|
|
56
|
+
for (const slug of slugs)
|
|
57
|
+
if (copy.run(slug, name).changes > 0)
|
|
58
|
+
wrote.push(slug);
|
|
59
|
+
// Dropped means "deleted and copied nowhere", which covers two cases:
|
|
60
|
+
// there was no project to copy into, and every project already defined
|
|
61
|
+
// the name and kept its own. The second changes nothing anyone could
|
|
62
|
+
// observe — the row was shadowed everywhere, so nothing ever read it —
|
|
63
|
+
// but it is still a deletion, and a migration that deletes in silence is
|
|
64
|
+
// one nobody can audit afterwards.
|
|
65
|
+
if (wrote.length > 0)
|
|
66
|
+
report.copied.push({ what, name, slugs: wrote });
|
|
67
|
+
else
|
|
68
|
+
report.dropped.push({ what, name });
|
|
69
|
+
}
|
|
70
|
+
db.prepare(`DELETE FROM ${table} WHERE project_slug = ''`).run();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
run();
|
|
74
|
+
return report;
|
|
75
|
+
}
|
package/dist/src/db/open.js
CHANGED
|
@@ -9,6 +9,10 @@ export function openDatabase(path) {
|
|
|
9
9
|
migrateIntrospectionCache(db);
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
db.exec(readFileSync(join(here, "schema.sql"), "utf8"));
|
|
12
|
+
// After the schema, not before: on a fresh database the table has to exist
|
|
13
|
+
// for the column check to mean anything, and there `CREATE TABLE` has already
|
|
14
|
+
// put the column in.
|
|
15
|
+
migrateEnvFileColumn(db);
|
|
12
16
|
return db;
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
@@ -25,3 +29,20 @@ function migrateIntrospectionCache(db) {
|
|
|
25
29
|
db.exec("DROP TABLE introspection_cache");
|
|
26
30
|
}
|
|
27
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Adds `secret.in_env_file` to a database written before the environment file
|
|
34
|
+
* existed.
|
|
35
|
+
*
|
|
36
|
+
* `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a column added
|
|
37
|
+
* to the schema never reaches a database that already has the table. The cache
|
|
38
|
+
* above answers that by dropping and rebuilding; here that would delete the
|
|
39
|
+
* vault. `ALTER TABLE … ADD COLUMN` with a default is the whole migration: every
|
|
40
|
+
* row that predates the column reads as "not in the file", which is exactly what
|
|
41
|
+
* it was.
|
|
42
|
+
*/
|
|
43
|
+
function migrateEnvFileColumn(db) {
|
|
44
|
+
const columns = db.prepare("PRAGMA table_info(secret)").all();
|
|
45
|
+
if (!columns.some((c) => c.name === "in_env_file")) {
|
|
46
|
+
db.exec("ALTER TABLE secret ADD COLUMN in_env_file INTEGER NOT NULL DEFAULT 0");
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/src/db/schema.sql
CHANGED
|
@@ -50,14 +50,19 @@ CREATE TABLE IF NOT EXISTS introspection_cache (
|
|
|
50
50
|
PRIMARY KEY (project_slug, kind)
|
|
51
51
|
);
|
|
52
52
|
|
|
53
|
-
--
|
|
54
|
-
--
|
|
55
|
-
--
|
|
53
|
+
-- Every secret belongs to one project. There was a second scope once — the
|
|
54
|
+
-- empty slug, read by every project — and `migrate-global-scope.ts` is what
|
|
55
|
+
-- became of the rows written under it.
|
|
56
|
+
-- `in_env_file` says this variable is also written into the file the build reads
|
|
57
|
+
-- from disk — see `runner/env-file.ts`. It is membership, not a second value: a
|
|
58
|
+
-- flagged secret still reaches the run as an environment variable like any
|
|
59
|
+
-- other. `open.ts` adds the column to a database written before it existed.
|
|
56
60
|
CREATE TABLE IF NOT EXISTS secret (
|
|
57
|
-
project_slug TEXT NOT NULL
|
|
61
|
+
project_slug TEXT NOT NULL,
|
|
58
62
|
key TEXT NOT NULL,
|
|
59
63
|
value_enc TEXT NOT NULL,
|
|
60
64
|
masked INTEGER NOT NULL DEFAULT 1,
|
|
65
|
+
in_env_file INTEGER NOT NULL DEFAULT 0,
|
|
61
66
|
updated_at TEXT NOT NULL,
|
|
62
67
|
PRIMARY KEY (project_slug, key)
|
|
63
68
|
);
|
|
@@ -74,7 +79,7 @@ CREATE TABLE IF NOT EXISTS secret (
|
|
|
74
79
|
-- `var_names` is NOT encrypted. It holds variable names, never values, and the
|
|
75
80
|
-- interface has to display them.
|
|
76
81
|
CREATE TABLE IF NOT EXISTS credential (
|
|
77
|
-
project_slug TEXT NOT NULL
|
|
82
|
+
project_slug TEXT NOT NULL,
|
|
78
83
|
kind TEXT NOT NULL,
|
|
79
84
|
file_name TEXT NOT NULL,
|
|
80
85
|
file_enc TEXT NOT NULL,
|
package/dist/src/db/secrets.js
CHANGED
|
@@ -1,50 +1,41 @@
|
|
|
1
|
-
const
|
|
1
|
+
const summaryOf = (row) => ({
|
|
2
|
+
key: row.key,
|
|
3
|
+
masked: row.masked === 1,
|
|
4
|
+
inEnvFile: row.in_env_file === 1,
|
|
5
|
+
});
|
|
2
6
|
/**
|
|
3
7
|
* Stores encrypted secrets. Knows nothing about encryption itself: it takes and
|
|
4
8
|
* returns ciphertext, so a bug here cannot leak a plaintext value.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
10
|
+
* Every row belongs to exactly one project. There used to be a second scope —
|
|
11
|
+
* a row under no project, read by all of them, shadowed by a project's own —
|
|
12
|
+
* and it is gone: see `migrate-global-scope.ts`. What it cost was the answer to
|
|
13
|
+
* "what does this project see", which was a merge of two sets that no screen
|
|
14
|
+
* ever showed whole. It is now one query.
|
|
8
15
|
*/
|
|
9
16
|
export class SecretStore {
|
|
10
17
|
db;
|
|
11
18
|
constructor(db) {
|
|
12
19
|
this.db = db;
|
|
13
20
|
}
|
|
14
|
-
set(projectSlug, key, valueEnc, masked) {
|
|
21
|
+
set(projectSlug, key, valueEnc, masked, inEnvFile = false) {
|
|
15
22
|
this.db
|
|
16
|
-
.prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, updated_at)
|
|
17
|
-
VALUES (?, ?, ?, ?, ?)
|
|
23
|
+
.prepare(`INSERT INTO secret (project_slug, key, value_enc, masked, in_env_file, updated_at)
|
|
24
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
18
25
|
ON CONFLICT (project_slug, key) DO UPDATE
|
|
19
26
|
SET value_enc = excluded.value_enc,
|
|
20
27
|
masked = excluded.masked,
|
|
28
|
+
in_env_file = excluded.in_env_file,
|
|
21
29
|
updated_at = excluded.updated_at`)
|
|
22
|
-
.run(projectSlug
|
|
30
|
+
.run(projectSlug, key, valueEnc, masked ? 1 : 0, inEnvFile ? 1 : 0, new Date().toISOString());
|
|
23
31
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const rows = this.db
|
|
27
|
-
.prepare("SELECT * FROM secret WHERE project_slug IN (?, ?) ORDER BY key")
|
|
28
|
-
.all(projectSlug, GLOBAL);
|
|
29
|
-
const byKey = new Map();
|
|
30
|
-
for (const row of rows) {
|
|
31
|
-
const existing = byKey.get(row.key);
|
|
32
|
-
if (!existing || row.project_slug !== GLOBAL)
|
|
33
|
-
byKey.set(row.key, row);
|
|
34
|
-
}
|
|
35
|
-
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
32
|
+
rows(projectSlug) {
|
|
33
|
+
return this.db.prepare("SELECT * FROM secret WHERE project_slug = ? ORDER BY key").all(projectSlug);
|
|
36
34
|
}
|
|
37
|
-
/** One
|
|
35
|
+
/** One row, ciphertext included, or undefined. */
|
|
38
36
|
find(projectSlug, key) {
|
|
39
|
-
const row = this.
|
|
40
|
-
return row
|
|
41
|
-
? {
|
|
42
|
-
key: row.key,
|
|
43
|
-
masked: row.masked === 1,
|
|
44
|
-
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
45
|
-
valueEnc: row.value_enc,
|
|
46
|
-
}
|
|
47
|
-
: undefined;
|
|
37
|
+
const row = this.rows(projectSlug).find((r) => r.key === key);
|
|
38
|
+
return row ? { ...summaryOf(row), valueEnc: row.value_enc } : undefined;
|
|
48
39
|
}
|
|
49
40
|
/**
|
|
50
41
|
* Flips whether a value is kept out of the logs, leaving the value alone.
|
|
@@ -60,58 +51,46 @@ export class SecretStore {
|
|
|
60
51
|
return (this.db
|
|
61
52
|
.prepare(`UPDATE secret SET masked = ?, updated_at = ?
|
|
62
53
|
WHERE project_slug = ? AND key = ?`)
|
|
63
|
-
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug
|
|
54
|
+
.run(masked ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
|
|
64
55
|
}
|
|
65
56
|
/**
|
|
66
|
-
*
|
|
57
|
+
* Flips whether this variable is written into the environment file, leaving
|
|
58
|
+
* the value and the masking alone.
|
|
67
59
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* secret three other projects also read is not this one's to count, and
|
|
71
|
-
* certainly not its to remove.
|
|
72
|
-
*
|
|
73
|
-
* The empty slug is the global scope's own key, so it is refused here rather
|
|
74
|
-
* than quietly returning every global row as if one project owned them.
|
|
60
|
+
* Its own operation for the same reason as `setMasked`: re-`set` would mean
|
|
61
|
+
* handing back a value the caller may not be able to read.
|
|
75
62
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
81
|
-
.all(projectSlug);
|
|
82
|
-
return rows.map((row) => ({ key: row.key, masked: row.masked === 1, scope: "project" }));
|
|
63
|
+
setInEnvFile(projectSlug, key, inEnvFile) {
|
|
64
|
+
return (this.db
|
|
65
|
+
.prepare(`UPDATE secret SET in_env_file = ?, updated_at = ?
|
|
66
|
+
WHERE project_slug = ? AND key = ?`)
|
|
67
|
+
.run(inEnvFile ? 1 : 0, new Date().toISOString(), projectSlug, key).changes > 0);
|
|
83
68
|
}
|
|
84
|
-
/** Removes every row
|
|
85
|
-
|
|
86
|
-
if (projectSlug === GLOBAL)
|
|
87
|
-
return 0;
|
|
69
|
+
/** Removes every row this project holds, and returns how many. */
|
|
70
|
+
removeAll(projectSlug) {
|
|
88
71
|
return this.db.prepare("DELETE FROM secret WHERE project_slug = ?").run(projectSlug).changes;
|
|
89
72
|
}
|
|
90
73
|
list(projectSlug) {
|
|
91
|
-
return this.
|
|
92
|
-
key: row.key,
|
|
93
|
-
masked: row.masked === 1,
|
|
94
|
-
scope: row.project_slug === GLOBAL ? "global" : "project",
|
|
95
|
-
}));
|
|
74
|
+
return this.rows(projectSlug).map(summaryOf);
|
|
96
75
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
101
|
-
|
|
76
|
+
/** The names that go in the environment file, in the order they are written. */
|
|
77
|
+
envFileKeys(projectSlug) {
|
|
78
|
+
return this.rows(projectSlug)
|
|
79
|
+
.filter((r) => r.in_env_file === 1)
|
|
80
|
+
.map((r) => r.key);
|
|
102
81
|
}
|
|
103
82
|
/** Ciphertext by name, for the vault to decrypt. */
|
|
104
83
|
encrypted(projectSlug) {
|
|
105
|
-
return Object.fromEntries(this.
|
|
84
|
+
return Object.fromEntries(this.rows(projectSlug).map((row) => [row.key, row.value_enc]));
|
|
106
85
|
}
|
|
107
|
-
/** Which of
|
|
86
|
+
/** Which of this project's secrets should be kept out of the logs. */
|
|
108
87
|
maskedKeys(projectSlug) {
|
|
109
|
-
return new Set(this.
|
|
88
|
+
return new Set(this.rows(projectSlug)
|
|
89
|
+
.filter((r) => r.masked === 1)
|
|
90
|
+
.map((r) => r.key));
|
|
110
91
|
}
|
|
111
92
|
remove(projectSlug, key) {
|
|
112
|
-
const res = this.db
|
|
113
|
-
.prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?")
|
|
114
|
-
.run(projectSlug ?? GLOBAL, key);
|
|
93
|
+
const res = this.db.prepare("DELETE FROM secret WHERE project_slug = ? AND key = ?").run(projectSlug, key);
|
|
115
94
|
return res.changes > 0;
|
|
116
95
|
}
|
|
117
96
|
}
|
|
@@ -53,6 +53,10 @@ export function proposalsFor(literals) {
|
|
|
53
53
|
const main = rewriteTo(literal, name);
|
|
54
54
|
const identifiers = file.kind === "apple_asc" ? appleIdentifierRewrites(appleIdentifiers) : EMPTY;
|
|
55
55
|
const edits = [...(main ? [main] : []), ...identifiers.edits];
|
|
56
|
+
const rewrites = [
|
|
57
|
+
...(main ? [{ arg: literal.arg, literal, varName: name }] : []),
|
|
58
|
+
...identifiers.rewrites,
|
|
59
|
+
];
|
|
56
60
|
// Every edit was a no-op — the value already reads its Laneyard name, and
|
|
57
61
|
// so did each identifier. Nothing to propose.
|
|
58
62
|
if (edits.length === 0)
|
|
@@ -69,6 +73,7 @@ export function proposalsFor(literals) {
|
|
|
69
73
|
},
|
|
70
74
|
literal,
|
|
71
75
|
edits,
|
|
76
|
+
rewrites,
|
|
72
77
|
checked: true,
|
|
73
78
|
});
|
|
74
79
|
continue;
|
|
@@ -96,6 +101,7 @@ export function proposalsFor(literals) {
|
|
|
96
101
|
},
|
|
97
102
|
...identifiers.edits,
|
|
98
103
|
],
|
|
104
|
+
rewrites: [{ arg: inline.becomes, literal, varName: name }, ...identifiers.rewrites],
|
|
99
105
|
checked: true,
|
|
100
106
|
});
|
|
101
107
|
continue;
|
|
@@ -108,6 +114,7 @@ export function proposalsFor(literals) {
|
|
|
108
114
|
literal,
|
|
109
115
|
suggestedFields: {},
|
|
110
116
|
edits: [{ start: literal.valueStart, length: literal.valueLength, replacement: `ENV.fetch("${name}")` }],
|
|
117
|
+
rewrites: [{ arg: literal.arg, literal, varName: name }],
|
|
111
118
|
// Unticked on purpose. This is the one tier where a false positive is
|
|
112
119
|
// likely — a non-secret URL, a placeholder — and a patch applied by
|
|
113
120
|
// default to a value that was not a secret is a silent regression in
|
|
@@ -119,7 +126,11 @@ export function proposalsFor(literals) {
|
|
|
119
126
|
return out;
|
|
120
127
|
}
|
|
121
128
|
/** Neither an edit nor a field: an `apple_asc` block that names no identifier. */
|
|
122
|
-
const EMPTY = {
|
|
129
|
+
const EMPTY = {
|
|
130
|
+
edits: [],
|
|
131
|
+
fields: {},
|
|
132
|
+
rewrites: [],
|
|
133
|
+
};
|
|
123
134
|
/**
|
|
124
135
|
* The rewrites and pre-filled fields for the identifiers written beside an
|
|
125
136
|
* App Store Connect key file.
|
|
@@ -133,17 +144,21 @@ const EMPTY = { edits: [], fields: {} };
|
|
|
133
144
|
function appleIdentifierRewrites(identifiers) {
|
|
134
145
|
const defaults = defaultVarNames("apple_asc");
|
|
135
146
|
const edits = [];
|
|
147
|
+
const rewrites = [];
|
|
136
148
|
const fields = {};
|
|
137
149
|
for (const id of identifiers) {
|
|
138
|
-
const
|
|
139
|
-
|
|
150
|
+
const name = defaults[id.arg];
|
|
151
|
+
const edit = rewriteTo(id, name);
|
|
152
|
+
if (edit) {
|
|
140
153
|
edits.push(edit);
|
|
154
|
+
rewrites.push({ arg: id.arg, literal: id, varName: name });
|
|
155
|
+
}
|
|
141
156
|
// Only a literal is the identifier's value; an env name is the *variable* it
|
|
142
157
|
// was read from, not the id itself, so it pre-fills nothing.
|
|
143
158
|
if (id.kind === "literal")
|
|
144
159
|
fields[id.arg] = id.value;
|
|
145
160
|
}
|
|
146
|
-
return { edits, fields };
|
|
161
|
+
return { edits, fields, rewrites };
|
|
147
162
|
}
|
|
148
163
|
/**
|
|
149
164
|
* The edit that makes one argument read `varName`, or null when it already does.
|
|
@@ -199,6 +199,34 @@ export class Workspace {
|
|
|
199
199
|
onProgress?.(`Cloning ${this.redact(this.gitUrl)}…`);
|
|
200
200
|
await this.git(["clone", this.gitUrl, this.path], process.cwd());
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Commits this workspace holds that `origin/<branch>` does not.
|
|
204
|
+
*
|
|
205
|
+
* The one thing `prepare` would destroy without saying so: it moves the local
|
|
206
|
+
* branch onto origin's, and a commit only this clone had becomes reachable
|
|
207
|
+
* from the reflog and nowhere a person looks. That is not hypothetical — the
|
|
208
|
+
* Fastfile tab commits and pushes as two separate acts on purpose, so a
|
|
209
|
+
* workspace sitting one commit ahead is a state the product produces itself.
|
|
210
|
+
*
|
|
211
|
+
* Counted rather than listed: the caller refuses on any number above zero, and
|
|
212
|
+
* a list would invite showing it, which is a screen nobody asked for.
|
|
213
|
+
*
|
|
214
|
+
* Zero when the branch does not exist on the remote yet, and zero when
|
|
215
|
+
* anything about the question cannot be answered — a workspace that has never
|
|
216
|
+
* been fetched has no `origin/<branch>` to compare against, and refusing a
|
|
217
|
+
* refresh because the comparison failed would break the one case the refresh
|
|
218
|
+
* exists for.
|
|
219
|
+
*/
|
|
220
|
+
async unpushedCount(branch) {
|
|
221
|
+
try {
|
|
222
|
+
const out = await this.git(["rev-list", "--count", `origin/${branch}..HEAD`]);
|
|
223
|
+
const n = Number(out.trim());
|
|
224
|
+
return Number.isInteger(n) ? n : 0;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
202
230
|
/**
|
|
203
231
|
* Brings the workspace to the requested branch, up to date.
|
|
204
232
|
* Clones on the first call, just fetches afterwards.
|