aethel 0.1.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/.env.example +2 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/docs/ARCHITECTURE.md +237 -0
- package/package.json +60 -0
- package/src/cli.js +1063 -0
- package/src/core/auth.js +288 -0
- package/src/core/config.js +117 -0
- package/src/core/diff.js +254 -0
- package/src/core/drive-api.js +1442 -0
- package/src/core/ignore.js +146 -0
- package/src/core/local-fs.js +109 -0
- package/src/core/remote-cache.js +65 -0
- package/src/core/snapshot.js +159 -0
- package/src/core/staging.js +125 -0
- package/src/core/sync.js +227 -0
- package/src/tui/app.js +1025 -0
- package/src/tui/index.js +10 -0
package/src/core/auth.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSyncFallback from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { URL } from "node:url";
|
|
7
|
+
import { google } from "googleapis";
|
|
8
|
+
import open from "open";
|
|
9
|
+
|
|
10
|
+
const SCOPES = ["https://www.googleapis.com/auth/drive"];
|
|
11
|
+
const DEFAULT_CREDENTIALS_PATH = "credentials.json";
|
|
12
|
+
const DEFAULT_TOKEN_PATH = "token.json";
|
|
13
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PROJECT_ROOT = path.resolve(MODULE_DIR, "..", "..");
|
|
15
|
+
const AUTH_TIMEOUT_MS = 120_000;
|
|
16
|
+
|
|
17
|
+
// ── Path resolution ─────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function resolvePath(candidatePath, fallbackFileName) {
|
|
20
|
+
if (candidatePath) {
|
|
21
|
+
return path.isAbsolute(candidatePath)
|
|
22
|
+
? candidatePath
|
|
23
|
+
: path.resolve(process.cwd(), candidatePath);
|
|
24
|
+
}
|
|
25
|
+
return path.join(PROJECT_ROOT, fallbackFileName);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveCredentialsPath(customPath) {
|
|
29
|
+
return resolvePath(
|
|
30
|
+
customPath || process.env.GOOGLE_DRIVE_CREDENTIALS_PATH,
|
|
31
|
+
DEFAULT_CREDENTIALS_PATH
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveTokenPath(customPath) {
|
|
36
|
+
return resolvePath(
|
|
37
|
+
customPath || process.env.GOOGLE_DRIVE_TOKEN_PATH,
|
|
38
|
+
DEFAULT_TOKEN_PATH
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Credential helpers ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function loadClientConfig(credentialsPath) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = await fs.readFile(credentialsPath, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`OAuth credentials file was not found. Expected path: ${credentialsPath}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const content = JSON.parse(raw);
|
|
55
|
+
const config = content.installed || content.web;
|
|
56
|
+
|
|
57
|
+
if (!config?.client_id || !config?.client_secret) {
|
|
58
|
+
throw new Error("OAuth credentials JSON is missing client configuration.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
clientId: config.client_id,
|
|
63
|
+
clientSecret: config.client_secret,
|
|
64
|
+
redirectUris: config.redirect_uris || [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createOAuthClient(config, redirectUri) {
|
|
69
|
+
const fallbackRedirect =
|
|
70
|
+
config.redirectUris.find(
|
|
71
|
+
(uri) =>
|
|
72
|
+
uri.startsWith("http://localhost") || uri.startsWith("http://127.0.0.1")
|
|
73
|
+
) ||
|
|
74
|
+
config.redirectUris[0] ||
|
|
75
|
+
"http://127.0.0.1";
|
|
76
|
+
|
|
77
|
+
return new google.auth.OAuth2(
|
|
78
|
+
config.clientId,
|
|
79
|
+
config.clientSecret,
|
|
80
|
+
redirectUri || fallbackRedirect
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function persistToken(tokenPath, credentials) {
|
|
85
|
+
await fs.mkdir(path.dirname(path.resolve(tokenPath)), { recursive: true });
|
|
86
|
+
await fs.writeFile(tokenPath, JSON.stringify(credentials, null, 2) + "\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function attachTokenPersistence(client, tokenPath) {
|
|
90
|
+
client.on("tokens", (tokens) => {
|
|
91
|
+
if (!tokens || Object.keys(tokens).length === 0) return;
|
|
92
|
+
persistToken(tokenPath, { ...client.credentials, ...tokens });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Token loading & validation ──────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function isTokenExpired(token) {
|
|
99
|
+
if (!token.expiry_date) return false;
|
|
100
|
+
return Date.now() >= token.expiry_date - 30_000;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadCachedClient(config, tokenPath) {
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = await fs.readFile(tokenPath, "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const token = JSON.parse(raw);
|
|
112
|
+
|
|
113
|
+
if (!token.access_token && !token.refresh_token) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const client = createOAuthClient(config);
|
|
118
|
+
attachTokenPersistence(client, tokenPath);
|
|
119
|
+
client.setCredentials(token);
|
|
120
|
+
|
|
121
|
+
// Only hit the network if the access token is expired / missing.
|
|
122
|
+
// If a refresh_token exists, googleapis will refresh automatically
|
|
123
|
+
// on the first real API call, so we can skip validation here when
|
|
124
|
+
// the token looks fresh.
|
|
125
|
+
if (token.refresh_token && !isTokenExpired(token)) {
|
|
126
|
+
return client;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await client.getAccessToken();
|
|
131
|
+
await persistToken(tokenPath, client.credentials);
|
|
132
|
+
return client;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Only fall through to browser auth for auth-specific errors.
|
|
135
|
+
// Network errors should propagate so the user knows what happened.
|
|
136
|
+
const status = err?.response?.status;
|
|
137
|
+
if (status === 401 || status === 403 || err?.code === "invalid_grant") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Browser OAuth flow ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function runLocalServerAuth(config, tokenPath) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
let settled = false;
|
|
149
|
+
let oauthClient = null;
|
|
150
|
+
let timer = null;
|
|
151
|
+
|
|
152
|
+
const finish = (error, client) => {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
settled = true;
|
|
155
|
+
if (timer) clearTimeout(timer);
|
|
156
|
+
server.close(() => {
|
|
157
|
+
if (error) reject(error);
|
|
158
|
+
else resolve(client);
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const server = http.createServer(async (req, res) => {
|
|
163
|
+
try {
|
|
164
|
+
if (!oauthClient) {
|
|
165
|
+
res.statusCode = 503;
|
|
166
|
+
res.end("OAuth client is not ready.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
|
|
171
|
+
const code = requestUrl.searchParams.get("code");
|
|
172
|
+
const authError = requestUrl.searchParams.get("error");
|
|
173
|
+
|
|
174
|
+
if (authError) {
|
|
175
|
+
res.statusCode = 400;
|
|
176
|
+
res.end("Authentication failed. The terminal will show the error.");
|
|
177
|
+
finish(new Error(`OAuth authorization failed: ${authError}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!code) {
|
|
182
|
+
res.statusCode = 400;
|
|
183
|
+
res.end("Authorization code is missing.");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { tokens } = await oauthClient.getToken(code);
|
|
188
|
+
oauthClient.setCredentials(tokens);
|
|
189
|
+
await persistToken(tokenPath, oauthClient.credentials);
|
|
190
|
+
res.end("Authentication completed. This browser tab can be closed.");
|
|
191
|
+
finish(null, oauthClient);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
res.statusCode = 500;
|
|
194
|
+
res.end("Authentication failed. See the terminal for details.");
|
|
195
|
+
finish(error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
server.on("error", (error) => finish(error));
|
|
200
|
+
|
|
201
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
202
|
+
try {
|
|
203
|
+
const address = server.address();
|
|
204
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
205
|
+
const redirectUri = `http://127.0.0.1:${port}/oauth2callback`;
|
|
206
|
+
|
|
207
|
+
oauthClient = createOAuthClient(config, redirectUri);
|
|
208
|
+
attachTokenPersistence(oauthClient, tokenPath);
|
|
209
|
+
|
|
210
|
+
const authUrl = oauthClient.generateAuthUrl({
|
|
211
|
+
access_type: "offline",
|
|
212
|
+
prompt: "consent",
|
|
213
|
+
scope: SCOPES,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
console.log("Opening browser for Google OAuth...");
|
|
217
|
+
console.log(`If the browser does not open, visit:\n${authUrl}`);
|
|
218
|
+
await open(authUrl).catch(() => undefined);
|
|
219
|
+
|
|
220
|
+
timer = setTimeout(() => {
|
|
221
|
+
finish(
|
|
222
|
+
new Error(
|
|
223
|
+
`OAuth timed out after ${AUTH_TIMEOUT_MS / 1000}s. Re-run 'aethel auth' to retry.`
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}, AUTH_TIMEOUT_MS);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
finish(error);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Singleton auth manager ──────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
let _authPromise = null;
|
|
237
|
+
let _authKey = null;
|
|
238
|
+
|
|
239
|
+
function authCacheKey(credentialsPath, tokenPath) {
|
|
240
|
+
return `${credentialsPath}\0${tokenPath}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Return an authenticated OAuth2 client. Concurrent callers with the
|
|
245
|
+
* same credential+token paths share a single in-flight auth attempt,
|
|
246
|
+
* preventing duplicate browser prompts and token-refresh races.
|
|
247
|
+
*/
|
|
248
|
+
export async function getAuthClient(credentialsPath, tokenPath) {
|
|
249
|
+
const resolvedCredentials = resolveCredentialsPath(credentialsPath);
|
|
250
|
+
const resolvedToken = resolveTokenPath(tokenPath);
|
|
251
|
+
const key = authCacheKey(resolvedCredentials, resolvedToken);
|
|
252
|
+
|
|
253
|
+
if (_authPromise && _authKey === key) {
|
|
254
|
+
return _authPromise;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_authKey = key;
|
|
258
|
+
_authPromise = (async () => {
|
|
259
|
+
const config = await loadClientConfig(resolvedCredentials);
|
|
260
|
+
const cached = await loadCachedClient(config, resolvedToken);
|
|
261
|
+
return cached || (await runLocalServerAuth(config, resolvedToken));
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
return await _authPromise;
|
|
266
|
+
} catch {
|
|
267
|
+
// Clear the cache on failure so the next call retries.
|
|
268
|
+
_authPromise = null;
|
|
269
|
+
_authKey = null;
|
|
270
|
+
throw arguments[0];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Clear the singleton so the next call re-authenticates. */
|
|
275
|
+
export function resetAuth() {
|
|
276
|
+
_authPromise = null;
|
|
277
|
+
_authKey = null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* High-level entry point: returns a googleapis drive client.
|
|
282
|
+
* Kept for backwards compatibility — prefer getAuthClient + google.drive
|
|
283
|
+
* for finer control.
|
|
284
|
+
*/
|
|
285
|
+
export async function authenticate(credentialsPath, tokenPath) {
|
|
286
|
+
const authClient = await getAuthClient(credentialsPath, tokenPath);
|
|
287
|
+
return google.drive({ version: "v3", auth: authClient });
|
|
288
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .aethel/ directory management, configuration, and state persistence.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
export const AETHEL_DIR = ".aethel";
|
|
9
|
+
export const CONFIG_FILE = "config.json";
|
|
10
|
+
export const INDEX_FILE = "index.json";
|
|
11
|
+
export const SNAPSHOTS_DIR = "snapshots";
|
|
12
|
+
export const HISTORY_DIR = "history";
|
|
13
|
+
export const LATEST_SNAPSHOT = "latest.json";
|
|
14
|
+
|
|
15
|
+
/** Walk up from `start` looking for a .aethel/ directory. */
|
|
16
|
+
export function findRoot(start = process.cwd()) {
|
|
17
|
+
let p = path.resolve(start);
|
|
18
|
+
while (true) {
|
|
19
|
+
if (fs.existsSync(path.join(p, AETHEL_DIR))) return p;
|
|
20
|
+
const parent = path.dirname(p);
|
|
21
|
+
if (parent === p) return null;
|
|
22
|
+
p = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return the workspace root or throw. */
|
|
27
|
+
export function requireRoot(start) {
|
|
28
|
+
const root = findRoot(start);
|
|
29
|
+
if (!root) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"Not an Aethel workspace (no .aethel/ found). Run 'aethel init' first."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dot(root) {
|
|
38
|
+
return path.join(root, AETHEL_DIR);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create a fresh .aethel/ workspace. Returns the root path. */
|
|
42
|
+
export function initWorkspace(localPath, driveFolderId = null, driveFolderName = "My Drive") {
|
|
43
|
+
const root = path.resolve(localPath);
|
|
44
|
+
const d = dot(root);
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(d)) {
|
|
47
|
+
throw new Error(`Workspace already initialised at ${d}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fs.mkdirSync(d, { recursive: true });
|
|
51
|
+
fs.mkdirSync(path.join(d, SNAPSHOTS_DIR));
|
|
52
|
+
fs.mkdirSync(path.join(d, SNAPSHOTS_DIR, HISTORY_DIR));
|
|
53
|
+
|
|
54
|
+
const config = {
|
|
55
|
+
version: 1,
|
|
56
|
+
drive_folder_id: driveFolderId,
|
|
57
|
+
drive_folder_name: driveFolderName,
|
|
58
|
+
local_path: root,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
writeConfig(root, config);
|
|
62
|
+
writeIndex(root, { staged: [] });
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── config helpers ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function readConfig(root) {
|
|
69
|
+
return JSON.parse(fs.readFileSync(path.join(dot(root), CONFIG_FILE), "utf-8"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function writeConfig(root, data) {
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(dot(root), CONFIG_FILE),
|
|
75
|
+
JSON.stringify(data, null, 2) + "\n"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── index (staging area) helpers ─────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function readIndex(root) {
|
|
82
|
+
const p = path.join(dot(root), INDEX_FILE);
|
|
83
|
+
if (!fs.existsSync(p)) return { staged: [] };
|
|
84
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeIndex(root, data) {
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(dot(root), INDEX_FILE),
|
|
90
|
+
JSON.stringify(data, null, 2) + "\n"
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── snapshot helpers ─────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function latestSnapshotPath(root) {
|
|
97
|
+
return path.join(dot(root), SNAPSHOTS_DIR, LATEST_SNAPSHOT);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readLatestSnapshot(root) {
|
|
101
|
+
const p = latestSnapshotPath(root);
|
|
102
|
+
if (!fs.existsSync(p)) return null;
|
|
103
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function writeSnapshot(root, snapshot) {
|
|
107
|
+
const snapDir = path.join(dot(root), SNAPSHOTS_DIR);
|
|
108
|
+
const latest = path.join(snapDir, LATEST_SNAPSHOT);
|
|
109
|
+
|
|
110
|
+
// Archive previous latest
|
|
111
|
+
if (fs.existsSync(latest)) {
|
|
112
|
+
const ts = new Date().toISOString().replace(/[:-]/g, "").replace(/\.\d+Z/, "Z");
|
|
113
|
+
fs.copyFileSync(latest, path.join(snapDir, HISTORY_DIR, `${ts}.json`));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fs.writeFileSync(latest, JSON.stringify(snapshot, null, 2) + "\n");
|
|
117
|
+
}
|
package/src/core/diff.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { isWorkspaceType } from "./drive-api.js";
|
|
2
|
+
import { loadIgnoreRules } from "./ignore.js";
|
|
3
|
+
|
|
4
|
+
export const ChangeType = Object.freeze({
|
|
5
|
+
REMOTE_ADDED: "remote_added",
|
|
6
|
+
REMOTE_MODIFIED: "remote_modified",
|
|
7
|
+
REMOTE_DELETED: "remote_deleted",
|
|
8
|
+
LOCAL_ADDED: "local_added",
|
|
9
|
+
LOCAL_MODIFIED: "local_modified",
|
|
10
|
+
LOCAL_DELETED: "local_deleted",
|
|
11
|
+
CONFLICT: "conflict",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const SHORT_STATUS = {
|
|
15
|
+
[ChangeType.REMOTE_ADDED]: "+R",
|
|
16
|
+
[ChangeType.REMOTE_MODIFIED]: "MR",
|
|
17
|
+
[ChangeType.REMOTE_DELETED]: "-R",
|
|
18
|
+
[ChangeType.LOCAL_ADDED]: "+L",
|
|
19
|
+
[ChangeType.LOCAL_MODIFIED]: "ML",
|
|
20
|
+
[ChangeType.LOCAL_DELETED]: "-L",
|
|
21
|
+
[ChangeType.CONFLICT]: "!!",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DESCRIPTION = {
|
|
25
|
+
[ChangeType.REMOTE_ADDED]: "new on Drive",
|
|
26
|
+
[ChangeType.REMOTE_MODIFIED]: "modified on Drive",
|
|
27
|
+
[ChangeType.REMOTE_DELETED]: "deleted on Drive",
|
|
28
|
+
[ChangeType.LOCAL_ADDED]: "new locally",
|
|
29
|
+
[ChangeType.LOCAL_MODIFIED]: "modified locally",
|
|
30
|
+
[ChangeType.LOCAL_DELETED]: "deleted locally",
|
|
31
|
+
[ChangeType.CONFLICT]: "both sides changed",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const SUGGESTED_ACTION = {
|
|
35
|
+
[ChangeType.REMOTE_ADDED]: "download",
|
|
36
|
+
[ChangeType.REMOTE_MODIFIED]: "download",
|
|
37
|
+
[ChangeType.REMOTE_DELETED]: "delete_local",
|
|
38
|
+
[ChangeType.LOCAL_ADDED]: "upload",
|
|
39
|
+
[ChangeType.LOCAL_MODIFIED]: "upload",
|
|
40
|
+
[ChangeType.LOCAL_DELETED]: "delete_remote",
|
|
41
|
+
[ChangeType.CONFLICT]: "conflict",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function createChange({
|
|
45
|
+
changeType,
|
|
46
|
+
path,
|
|
47
|
+
fileId = null,
|
|
48
|
+
remoteMeta = null,
|
|
49
|
+
localMeta = null,
|
|
50
|
+
snapshotMeta = null,
|
|
51
|
+
}) {
|
|
52
|
+
return {
|
|
53
|
+
changeType,
|
|
54
|
+
path,
|
|
55
|
+
fileId,
|
|
56
|
+
remoteMeta,
|
|
57
|
+
localMeta,
|
|
58
|
+
snapshotMeta,
|
|
59
|
+
shortStatus: SHORT_STATUS[changeType],
|
|
60
|
+
description: DESCRIPTION[changeType],
|
|
61
|
+
suggestedAction: SUGGESTED_ACTION[changeType],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildDiffResult(changes) {
|
|
66
|
+
return {
|
|
67
|
+
changes,
|
|
68
|
+
get remoteChanges() {
|
|
69
|
+
return this.changes.filter((change) =>
|
|
70
|
+
change.changeType.startsWith("remote")
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
get localChanges() {
|
|
74
|
+
return this.changes.filter((change) =>
|
|
75
|
+
change.changeType.startsWith("local")
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
get conflicts() {
|
|
79
|
+
return this.changes.filter(
|
|
80
|
+
(change) => change.changeType === ChangeType.CONFLICT
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
get isClean() {
|
|
84
|
+
return this.changes.length === 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function remoteChanged(snapshotEntry, remoteEntry) {
|
|
90
|
+
if (isWorkspaceType(remoteEntry.mimeType || "")) {
|
|
91
|
+
return snapshotEntry.modifiedTime !== remoteEntry.modifiedTime;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return snapshotEntry.md5Checksum !== remoteEntry.md5Checksum;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function localChanged(snapshotEntry, localEntry) {
|
|
98
|
+
return snapshotEntry.md5 !== localEntry.md5;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function promoteConflicts(changes) {
|
|
102
|
+
const remoteByPath = new Map();
|
|
103
|
+
const localByPath = new Map();
|
|
104
|
+
|
|
105
|
+
for (const change of changes) {
|
|
106
|
+
if (change.changeType.startsWith("remote")) {
|
|
107
|
+
remoteByPath.set(change.path, change);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (change.changeType.startsWith("local")) {
|
|
112
|
+
localByPath.set(change.path, change);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const conflictPathSet = new Set();
|
|
117
|
+
for (const pathValue of remoteByPath.keys()) {
|
|
118
|
+
if (localByPath.has(pathValue)) {
|
|
119
|
+
conflictPathSet.add(pathValue);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (conflictPathSet.size === 0) {
|
|
124
|
+
return changes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const filtered = changes.filter(
|
|
128
|
+
(change) => !conflictPathSet.has(change.path)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
for (const pathValue of [...conflictPathSet].sort()) {
|
|
132
|
+
const remoteChange = remoteByPath.get(pathValue);
|
|
133
|
+
const localChange = localByPath.get(pathValue);
|
|
134
|
+
|
|
135
|
+
filtered.push(
|
|
136
|
+
createChange({
|
|
137
|
+
changeType: ChangeType.CONFLICT,
|
|
138
|
+
path: pathValue,
|
|
139
|
+
fileId: remoteChange.fileId,
|
|
140
|
+
remoteMeta: remoteChange.remoteMeta,
|
|
141
|
+
localMeta: localChange.localMeta,
|
|
142
|
+
snapshotMeta: remoteChange.snapshotMeta || localChange.snapshotMeta,
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return filtered;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {object|null} snapshot
|
|
152
|
+
* @param {object[]} remoteFiles
|
|
153
|
+
* @param {object} localFiles
|
|
154
|
+
* @param {{ root?: string, respectIgnore?: boolean }} options
|
|
155
|
+
*/
|
|
156
|
+
export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIgnore = true } = {}) {
|
|
157
|
+
const ignoreRules = root && respectIgnore ? loadIgnoreRules(root) : null;
|
|
158
|
+
|
|
159
|
+
// Pre-filter remote files by ignore rules
|
|
160
|
+
if (ignoreRules) {
|
|
161
|
+
remoteFiles = remoteFiles.filter((f) => !ignoreRules.ignores(f.path));
|
|
162
|
+
}
|
|
163
|
+
const changes = [];
|
|
164
|
+
const snapshotFiles = snapshot?.files || {};
|
|
165
|
+
const snapshotLocalFiles = snapshot?.localFiles || {};
|
|
166
|
+
const snapshotById = new Map();
|
|
167
|
+
|
|
168
|
+
for (const [fileId, meta] of Object.entries(snapshotFiles)) {
|
|
169
|
+
snapshotById.set(fileId, meta);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const remoteById = new Map(remoteFiles.map((file) => [file.id, file]));
|
|
173
|
+
|
|
174
|
+
for (const remoteFile of remoteFiles) {
|
|
175
|
+
const snapshotEntry = snapshotById.get(remoteFile.id);
|
|
176
|
+
|
|
177
|
+
if (!snapshotEntry) {
|
|
178
|
+
changes.push(
|
|
179
|
+
createChange({
|
|
180
|
+
changeType: ChangeType.REMOTE_ADDED,
|
|
181
|
+
path: remoteFile.path,
|
|
182
|
+
fileId: remoteFile.id,
|
|
183
|
+
remoteMeta: remoteFile,
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (remoteChanged(snapshotEntry, remoteFile)) {
|
|
190
|
+
changes.push(
|
|
191
|
+
createChange({
|
|
192
|
+
changeType: ChangeType.REMOTE_MODIFIED,
|
|
193
|
+
path: remoteFile.path,
|
|
194
|
+
fileId: remoteFile.id,
|
|
195
|
+
remoteMeta: remoteFile,
|
|
196
|
+
snapshotMeta: snapshotEntry,
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [fileId, snapshotEntry] of snapshotById.entries()) {
|
|
203
|
+
if (!remoteById.has(fileId)) {
|
|
204
|
+
changes.push(
|
|
205
|
+
createChange({
|
|
206
|
+
changeType: ChangeType.REMOTE_DELETED,
|
|
207
|
+
path: snapshotEntry.path || snapshotEntry.localPath || "",
|
|
208
|
+
fileId,
|
|
209
|
+
snapshotMeta: snapshotEntry,
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const [relativePath, localMeta] of Object.entries(localFiles)) {
|
|
216
|
+
const snapshotEntry = snapshotLocalFiles[relativePath];
|
|
217
|
+
|
|
218
|
+
if (!snapshotEntry) {
|
|
219
|
+
changes.push(
|
|
220
|
+
createChange({
|
|
221
|
+
changeType: ChangeType.LOCAL_ADDED,
|
|
222
|
+
path: relativePath,
|
|
223
|
+
localMeta,
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (localChanged(snapshotEntry, localMeta)) {
|
|
230
|
+
changes.push(
|
|
231
|
+
createChange({
|
|
232
|
+
changeType: ChangeType.LOCAL_MODIFIED,
|
|
233
|
+
path: relativePath,
|
|
234
|
+
localMeta,
|
|
235
|
+
snapshotMeta: snapshotEntry,
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
|
|
242
|
+
if (!(relativePath in localFiles)) {
|
|
243
|
+
changes.push(
|
|
244
|
+
createChange({
|
|
245
|
+
changeType: ChangeType.LOCAL_DELETED,
|
|
246
|
+
path: relativePath,
|
|
247
|
+
snapshotMeta: snapshotEntry,
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return buildDiffResult(promoteConflicts(changes));
|
|
254
|
+
}
|