@kabmo-labs/kabmo 0.1.0 → 0.2.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/dist/client/index.html +1 -1
- package/dist/client/index.js +285 -285
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-enkbbffs.js → index-zsskckw7.js} +671 -671
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +5 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +5 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- package/dist/client/static/js/index-yn17t04b.css +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `sync` ACTION, not just the pull under it.
|
|
8
|
+
*
|
|
9
|
+
* `pullBranch` is pinned by `git-publish.test.ts`; what is pinned here is the
|
|
10
|
+
* handler's own two rules, both invisible from the pull alone:
|
|
11
|
+
*
|
|
12
|
+
* - A pull that stops on a conflict SKIPS the push. `git push` refuses while
|
|
13
|
+
* the merge is unresolved, so without the skip the route answered 400 over a
|
|
14
|
+
* conflict the operation bar was about to offer to resolve — the reported
|
|
15
|
+
* "Sync failed" on the exact state the panel has a surface for.
|
|
16
|
+
* - A tree already in an operation is refused BY NAME rather than by git's
|
|
17
|
+
* "You have not concluded your merge" — the panel can only act on an
|
|
18
|
+
* operation it knows about, and an error that names it is what points the
|
|
19
|
+
* user at the bar.
|
|
20
|
+
*
|
|
21
|
+
* Driven against real repositories: the exit codes, the merge commit and the
|
|
22
|
+
* `MERGE_HEAD` file are the whole subject.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { GIT_BRANCH_ACTIONS } from '@/server/lib/fs/git-actions-branch';
|
|
30
|
+
import { removeDir } from '@/test-support/remove-dir';
|
|
31
|
+
|
|
32
|
+
const tempDirs: string[] = [];
|
|
33
|
+
|
|
34
|
+
function tempDir(): string {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-gitsync-action-'));
|
|
36
|
+
tempDirs.push(dir);
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(cwd: string, ...args: string[]): string {
|
|
41
|
+
const result = Bun.spawnSync({ cmd: ['git', ...args], cwd });
|
|
42
|
+
if (result.exitCode !== 0) {
|
|
43
|
+
throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`);
|
|
44
|
+
}
|
|
45
|
+
return result.stdout.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function initRepo(cwd: string): void {
|
|
49
|
+
git(cwd, 'init', '-q', '-b', 'main');
|
|
50
|
+
git(cwd, 'config', 'user.email', 'test@example.invalid');
|
|
51
|
+
git(cwd, 'config', 'user.name', 'Test');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function commitFile(cwd: string, name: string, message: string, content?: string): void {
|
|
55
|
+
fs.writeFileSync(path.join(cwd, name), content ?? `${name}\n`);
|
|
56
|
+
git(cwd, 'add', '-A');
|
|
57
|
+
git(cwd, 'commit', '-q', '-m', message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A bare origin plus a clone tracking it, each on its own temp path. */
|
|
61
|
+
function makeClone(): { origin: string; clone: string } {
|
|
62
|
+
const origin = tempDir();
|
|
63
|
+
git(origin, 'init', '-q', '--bare', '-b', 'main');
|
|
64
|
+
const clone = tempDir();
|
|
65
|
+
git(tempDir(), 'clone', '-q', origin, clone);
|
|
66
|
+
initRepo(clone);
|
|
67
|
+
commitFile(clone, 'seed.txt', 'seed');
|
|
68
|
+
git(clone, 'push', '-q', '-u', 'origin', 'main');
|
|
69
|
+
return { origin, clone };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Commit `name` in a second clone of `origin` and push it. */
|
|
73
|
+
function pushFromOtherClone(origin: string, name: string, message: string, content?: string): void {
|
|
74
|
+
const other = tempDir();
|
|
75
|
+
git(tempDir(), 'clone', '-q', origin, other);
|
|
76
|
+
initRepo(other);
|
|
77
|
+
commitFile(other, name, message, content);
|
|
78
|
+
git(other, 'push', '-q');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function counts(cwd: string): string {
|
|
82
|
+
return git(cwd, 'rev-list', '--count', '--left-right', 'HEAD...@{upstream}').trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One sync, through the same handler the route dispatches. */
|
|
86
|
+
async function sync(cwd: string): Promise<void> {
|
|
87
|
+
const handler = GIT_BRANCH_ACTIONS.sync;
|
|
88
|
+
await handler({ formData: new FormData(), targetDir: cwd });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
for (const dir of tempDirs) removeDir(dir);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('the sync action', () => {
|
|
96
|
+
test('reconciles a diverged branch and publishes the result', async () => {
|
|
97
|
+
const { origin, clone } = makeClone();
|
|
98
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
99
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
100
|
+
git(clone, 'fetch', '-q');
|
|
101
|
+
expect(counts(clone)).toBe('1\t1');
|
|
102
|
+
|
|
103
|
+
await sync(clone);
|
|
104
|
+
|
|
105
|
+
// Both sides landed and the remote has them: nothing ahead or behind.
|
|
106
|
+
expect(counts(clone)).toBe('0\t0');
|
|
107
|
+
expect(fs.existsSync(path.join(clone, 'other.txt'))).toBe(true);
|
|
108
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('leaves a conflicted merge open and does not push over it', async () => {
|
|
112
|
+
const { origin, clone } = makeClone();
|
|
113
|
+
commitFile(clone, 'seed.txt', 'mine', 'mine\n');
|
|
114
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
115
|
+
git(clone, 'fetch', '-q');
|
|
116
|
+
|
|
117
|
+
// The pull exits 1 with MERGE_HEAD in place; the handler must treat that as
|
|
118
|
+
// the state to show, not as a failure.
|
|
119
|
+
await sync(clone);
|
|
120
|
+
|
|
121
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
122
|
+
expect(git(clone, 'status', '--porcelain=v1')).toContain('UU seed.txt');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('refuses by name while an operation is already in progress', async () => {
|
|
126
|
+
const { origin, clone } = makeClone();
|
|
127
|
+
commitFile(clone, 'seed.txt', 'mine', 'mine\n');
|
|
128
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
129
|
+
git(clone, 'fetch', '-q');
|
|
130
|
+
// Start the merge the way the panel does, then try to sync over it.
|
|
131
|
+
Bun.spawnSync({ cmd: ['git', 'merge', '--no-edit', '--', '@{upstream}'], cwd: clone });
|
|
132
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
133
|
+
|
|
134
|
+
// `expect(...).rejects` is typed as returning void by Bun's matcher types,
|
|
135
|
+
// so awaiting it is a no-op — the explicit catch is what actually waits.
|
|
136
|
+
let refusal = '';
|
|
137
|
+
try {
|
|
138
|
+
await sync(clone);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
refusal = error instanceof Error ? error.message : String(error);
|
|
141
|
+
}
|
|
142
|
+
expect(refusal).toMatch(/finish the merge first/i);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* refusal, not a failure — and treating it as an error aborted Sync before it
|
|
14
14
|
* ever reached the push. Driven against real repositories, because the exit
|
|
15
15
|
* codes and the `-u` upstream write are what the module exists for.
|
|
16
|
+
*
|
|
17
|
+
* The pull suite also pins the DIVERGED case, where `--ff-only` exits 128 and
|
|
18
|
+
* Sync silently did nothing (one local commit, one on origin) — see the merge
|
|
19
|
+
* test and `pullBranch`'s own comment.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
@@ -66,6 +70,32 @@ function upstreamOf(cwd: string): string {
|
|
|
66
70
|
return git(cwd, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').trim();
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** "<ahead>\t<behind>" against the upstream — `1\t1` is a diverged branch. */
|
|
74
|
+
function counts(cwd: string): string {
|
|
75
|
+
return git(cwd, 'rev-list', '--count', '--left-right', 'HEAD...@{upstream}').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A second clone of `origin` that commits `name` and pushes it, so the branch
|
|
80
|
+
* under test is behind by one — the other half of a divergence.
|
|
81
|
+
*
|
|
82
|
+
* `content` overrides what the file holds, which is what makes the two sides
|
|
83
|
+
* CONFLICT instead of one of them being an unrelated new path.
|
|
84
|
+
*/
|
|
85
|
+
function pushFromOtherClone(origin: string, name: string, message: string, content?: string): void {
|
|
86
|
+
const other = tempDir();
|
|
87
|
+
git(tempDir(), 'clone', '-q', origin, other);
|
|
88
|
+
initRepo(other);
|
|
89
|
+
if (content === undefined) {
|
|
90
|
+
commitFile(other, name, message);
|
|
91
|
+
} else {
|
|
92
|
+
fs.writeFileSync(path.join(other, name), content);
|
|
93
|
+
git(other, 'add', '-A');
|
|
94
|
+
git(other, 'commit', '-q', '-m', message);
|
|
95
|
+
}
|
|
96
|
+
git(other, 'push', '-q');
|
|
97
|
+
}
|
|
98
|
+
|
|
69
99
|
afterAll(() => {
|
|
70
100
|
for (const dir of tempDirs) removeDir(dir);
|
|
71
101
|
});
|
|
@@ -144,4 +174,70 @@ describe('pullBranch', () => {
|
|
|
144
174
|
|
|
145
175
|
expect(fs.existsSync(path.join(clone, 'remote.txt'))).toBe(true);
|
|
146
176
|
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The reported Sync failure: one local commit and one on origin.
|
|
180
|
+
*
|
|
181
|
+
* `--ff-only` exits 128 here ("Not possible to fast-forward, aborting."), so
|
|
182
|
+
* Sync threw before the push and the badge stayed at ↑1 ↓1 with git's advice
|
|
183
|
+
* text on screen. The fix is the merge pull, and the assertion is the state
|
|
184
|
+
* after a REAL push: nothing left ahead or behind.
|
|
185
|
+
*/
|
|
186
|
+
test('merges a diverged branch instead of refusing, and the push then lands', async () => {
|
|
187
|
+
const { origin, clone } = makeClone();
|
|
188
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
189
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
190
|
+
git(clone, 'fetch', '-q');
|
|
191
|
+
expect(counts(clone)).toBe('1\t1');
|
|
192
|
+
|
|
193
|
+
await pullBranch(clone);
|
|
194
|
+
await pushBranch(clone);
|
|
195
|
+
|
|
196
|
+
expect(counts(clone)).toBe('0\t0');
|
|
197
|
+
// The merge commit carries the remote branch's name, which is what a pull
|
|
198
|
+
// writes — not a rebase, whose history would hold two linear commits.
|
|
199
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
200
|
+
expect(fs.existsSync(path.join(clone, 'other.txt'))).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A pull that stops on a conflict exits 1 with `MERGE_HEAD` in place — the
|
|
205
|
+
* state the operation bar exists to show, not a failed sync. Throwing there
|
|
206
|
+
* reported the conflict as an error and never reached the panel's own
|
|
207
|
+
* "resolve the conflicts" surface.
|
|
208
|
+
*/
|
|
209
|
+
test('accepts a pull that stops on a conflict, leaving the merge open', async () => {
|
|
210
|
+
const { origin, clone } = makeClone();
|
|
211
|
+
// `seed.txt` exists on both sides already, so the two edits CONFLICT —
|
|
212
|
+
// a file only one side has would merge cleanly (`AA` only when both
|
|
213
|
+
// created it, which is a different shape than a content conflict).
|
|
214
|
+
fs.writeFileSync(path.join(clone, 'seed.txt'), 'mine\n');
|
|
215
|
+
git(clone, 'commit', '-q', '-am', 'mine');
|
|
216
|
+
pushFromOtherClone(origin, 'seed.txt', 'theirs', 'theirs\n');
|
|
217
|
+
git(clone, 'fetch', '-q');
|
|
218
|
+
|
|
219
|
+
await pullBranch(clone);
|
|
220
|
+
|
|
221
|
+
expect(fs.existsSync(path.join(clone, '.git', 'MERGE_HEAD'))).toBe(true);
|
|
222
|
+
expect(git(clone, 'status', '--porcelain=v1')).toContain('UU seed.txt');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The pull is pinned to merge semantics whatever the repository's own
|
|
227
|
+
* `pull.ff`/`pull.rebase` say — a Sync button must not mean two things.
|
|
228
|
+
*/
|
|
229
|
+
test('does not inherit pull.ff=only, which reproduces the refusal', async () => {
|
|
230
|
+
const { origin, clone } = makeClone();
|
|
231
|
+
pushFromOtherClone(origin, 'other.txt', 'other');
|
|
232
|
+
commitFile(clone, 'mine.txt', 'mine');
|
|
233
|
+
git(clone, 'fetch', '-q');
|
|
234
|
+
git(clone, 'config', 'pull.ff', 'only');
|
|
235
|
+
|
|
236
|
+
await pullBranch(clone);
|
|
237
|
+
|
|
238
|
+
// Ahead by the local commit plus the merge commit, and no longer behind:
|
|
239
|
+
// the pull brought origin in rather than refusing it.
|
|
240
|
+
expect(counts(clone)).toBe('2\t0');
|
|
241
|
+
expect(git(clone, 'log', '-1', '--pretty=%s')).toContain('Merge branch');
|
|
242
|
+
});
|
|
147
243
|
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* branch" when the branch was never published, so Sync failed outright on
|
|
14
14
|
* exactly the branch whose commits it was supposed to send. `push -u <remote>
|
|
15
15
|
* HEAD` sets the upstream while it publishes.
|
|
16
|
-
* - `git pull
|
|
16
|
+
* - `git pull` exits 1 with "There is no tracking information for the
|
|
17
17
|
* current branch" for the same branch — a refusal, not a failure of the
|
|
18
18
|
* user's intent: there is nothing to pull yet. Treating it as an error
|
|
19
19
|
* aborted Sync before it reached the push.
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { runGit, type GitRunResult } from '@/server/lib/fs/git-run';
|
|
26
|
+
import { readGitState } from '@/server/lib/fs/git-state';
|
|
26
27
|
|
|
27
28
|
/** Runs a git command and throws with its stderr when it exits non-zero. */
|
|
28
29
|
async function expectOk(args: string[], cwd: string, timeout?: number): Promise<GitRunResult> {
|
|
@@ -62,12 +63,43 @@ export async function pushBranch(cwd: string): Promise<void> {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* Pull
|
|
66
|
+
* Pull the branch's upstream, standing down on a branch with no upstream.
|
|
66
67
|
*
|
|
67
68
|
* Nothing to pull is not an error, and aborting here is what stopped Sync from
|
|
68
69
|
* ever publishing a fresh branch.
|
|
70
|
+
*
|
|
71
|
+
* **`--ff-only` was wrong, and it is the reported "Sync does nothing when I
|
|
72
|
+
* have one local commit and one on origin".** On a DIVERGED branch git refuses
|
|
73
|
+
* it outright — measured: `fatal: Not possible to fast-forward, aborting.`
|
|
74
|
+
* (exit 128) — so Sync threw before it reached the push, the badge stayed at
|
|
75
|
+
* `↑1 ↓1`, and the panel reported git's advice text as an error. The two
|
|
76
|
+
* branches cannot be reconciled by moving a pointer; they need a merge commit,
|
|
77
|
+
* which is what `git pull` does by default. `--ff --no-rebase` states that
|
|
78
|
+
* explicitly instead of inheriting the user's `pull.rebase`/`pull.ff`: both
|
|
79
|
+
* were measured to change the answer (`pull.ff=only` reproduces the refusal,
|
|
80
|
+
* `pull.rebase=true` rewrites local commits), and a Sync button must not mean
|
|
81
|
+
* two different things per repository. A fast-forward still happens where one
|
|
82
|
+
* is possible — verified against a behind-only branch.
|
|
83
|
+
*
|
|
84
|
+
* **A merge that stops on a conflict is SUCCESS, not a failure.** Measured:
|
|
85
|
+
* the command exits 1 with `MERGE_HEAD` in place and `UU` rows in the status —
|
|
86
|
+
* the state the panel's operation bar exists to show. Throwing there reported
|
|
87
|
+
* the conflict as a failed sync and hid the bar's own instructions, so the
|
|
88
|
+
* caller checks for a started merge exactly as `expectStarted` does for a
|
|
89
|
+
* merge the user asked for.
|
|
69
90
|
*/
|
|
70
91
|
export async function pullBranch(cwd: string): Promise<void> {
|
|
71
92
|
if (!(await readUpstream(cwd))) return;
|
|
72
|
-
await
|
|
93
|
+
const result = await runGit(['pull', '--ff', '--no-rebase', '--no-edit'], {
|
|
94
|
+
cwd,
|
|
95
|
+
timeout: 120_000,
|
|
96
|
+
maxBuffer: 1024 * 1024,
|
|
97
|
+
});
|
|
98
|
+
if (result.error) throw result.error;
|
|
99
|
+
if (result.exitCode === 0) return;
|
|
100
|
+
const state = await readGitState(cwd);
|
|
101
|
+
if (state.operation === 'merge') return;
|
|
102
|
+
throw new Error(
|
|
103
|
+
result.stderr.trim() || result.stdout.trim() || `git exited with code ${result.exitCode}`,
|
|
104
|
+
);
|
|
73
105
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the Files panel lists.
|
|
8
|
+
*
|
|
9
|
+
* The panel used to drop a hardcoded set of names (`node_modules`, `.git`,
|
|
10
|
+
* `dist`, `build`) at every depth, which hid `packages/android/dist` — the
|
|
11
|
+
* built APKs — from the user with no way to reach it. There is now no name
|
|
12
|
+
* filter at all: every entry is listed, and the `ignored` flag (one
|
|
13
|
+
* `git check-ignore` per listing) is what marks the untracked ones for dimming.
|
|
14
|
+
*
|
|
15
|
+
* Driven against a real temp directory, because the listing stats every entry
|
|
16
|
+
* and asks git about it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { listDirectoryEntries } from '@/server/lib/fs/listing';
|
|
24
|
+
|
|
25
|
+
const roots: string[] = [];
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (roots.length > 0) fs.rmSync(roots.pop() as string, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeRoot(): string {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-listing-'));
|
|
33
|
+
roots.push(dir);
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkdirs(root: string, names: string[]): void {
|
|
38
|
+
for (const name of names) fs.mkdirSync(path.join(root, name), { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('listDirectoryEntries', () => {
|
|
42
|
+
test('lists every entry, node_modules and .git included', async () => {
|
|
43
|
+
const root = makeRoot();
|
|
44
|
+
mkdirs(root, ['src', 'dist', 'build', 'node_modules/preact', '.git/objects']);
|
|
45
|
+
|
|
46
|
+
const names = (await listDirectoryEntries(root, root)).map((entry) => entry.name);
|
|
47
|
+
|
|
48
|
+
expect(names).toContain('dist');
|
|
49
|
+
expect(names).toContain('build');
|
|
50
|
+
expect(names).toContain('src');
|
|
51
|
+
// No name table: the panel must be able to reach anything on disk.
|
|
52
|
+
expect(names).toContain('node_modules');
|
|
53
|
+
expect(names).toContain('.git');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a nested dist is listed where it lives, not filtered by name', async () => {
|
|
57
|
+
const root = makeRoot();
|
|
58
|
+
mkdirs(root, ['packages/android/dist', 'packages/core']);
|
|
59
|
+
|
|
60
|
+
const packages = await listDirectoryEntries(path.join(root, 'packages'), root);
|
|
61
|
+
expect(packages.map((entry) => entry.name)).toEqual(['android', 'core']);
|
|
62
|
+
|
|
63
|
+
const android = await listDirectoryEntries(path.join(root, 'packages/android'), root);
|
|
64
|
+
expect(android.map((entry) => entry.name)).toEqual(['dist']);
|
|
65
|
+
// Paths stay relative to the LISTED root, which is what the tree keys on,
|
|
66
|
+
// and they are POSIX-spelled whatever the platform: `path.relative` answers
|
|
67
|
+
// `packages\android\dist` on Windows, which keyed every row differently from
|
|
68
|
+
// the same row on POSIX and — because `git check-ignore` rejects a
|
|
69
|
+
// backslash — left the whole listing un-dimmed there. This assertion is the
|
|
70
|
+
// guard; it is what failed on the Windows CI leg.
|
|
71
|
+
expect(android[0]!.path).toBe('packages/android/dist');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('folders sort before files, and dot-entries are listed', async () => {
|
|
75
|
+
const root = makeRoot();
|
|
76
|
+
fs.mkdirSync(path.join(root, 'zebra'));
|
|
77
|
+
fs.writeFileSync(path.join(root, 'alpha.txt'), '');
|
|
78
|
+
fs.writeFileSync(path.join(root, '.env'), 'K=1\n');
|
|
79
|
+
|
|
80
|
+
const entries = await listDirectoryEntries(root, root);
|
|
81
|
+
const names = entries.map((entry) => entry.name);
|
|
82
|
+
|
|
83
|
+
expect(entries[0]!.type).toBe('folder');
|
|
84
|
+
expect(names).toEqual(['zebra', '.env', 'alpha.txt']);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -17,9 +17,6 @@ import fs from 'node:fs';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
|
|
19
19
|
|
|
20
|
-
/** Directory names never listed: build output and VCS metadata. */
|
|
21
|
-
const NOISE_DIRS: Record<string, true> = { node_modules: true, '.git': true, dist: true, build: true };
|
|
22
|
-
|
|
23
20
|
export interface DirectoryEntry {
|
|
24
21
|
id: string;
|
|
25
22
|
name: string;
|
|
@@ -30,13 +27,30 @@ export interface DirectoryEntry {
|
|
|
30
27
|
ignored: boolean;
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Read one directory, folders first then files, each marked ignored or not.
|
|
32
|
+
*
|
|
33
|
+
* EVERY entry is listed — `node_modules`, `.git`, `dist`, `build` and dot-files
|
|
34
|
+
* included. There is no name table: filtering by name cannot tell a dependency
|
|
35
|
+
* tree from a folder of release artifacts (`packages/android/dist` holds the
|
|
36
|
+
* built APKs), and it hid directories the user could not reach any other way.
|
|
37
|
+
* The panel's own signal for "this is untracked noise" is the `ignored` flag,
|
|
38
|
+
* which dims the row via `git check-ignore` instead of deleting it — and a tree
|
|
39
|
+
* that is expensive to expand costs nothing until someone expands it, because
|
|
40
|
+
* children are lazy.
|
|
41
|
+
*/
|
|
34
42
|
export async function listDirectoryEntries(dirPath: string, rootPath: string): Promise<DirectoryEntry[]> {
|
|
35
43
|
const listed = (await fs.promises.readdir(dirPath))
|
|
36
|
-
.filter((child) => !NOISE_DIRS[child])
|
|
37
44
|
.map((child) => {
|
|
38
45
|
const full = path.join(dirPath, child);
|
|
39
|
-
|
|
46
|
+
// POSIX spelling, because `rel` is not only a display value: it is the
|
|
47
|
+
// tree's id/key, the `fs:` topic's path, and the path handed to
|
|
48
|
+
// `git check-ignore` — which rejects a backslash outright, so on Windows
|
|
49
|
+
// every row came back un-dimmed and keyed `packages\android\dist`. The
|
|
50
|
+
// rest of the server states the same convention (`lib/fs/root.ts`,
|
|
51
|
+
// `lib/fs/transfer.ts`).
|
|
52
|
+
const rel = path.relative(rootPath, full).split(path.sep).join('/');
|
|
53
|
+
return { child, full, rel };
|
|
40
54
|
});
|
|
41
55
|
|
|
42
56
|
const ignored = await collectIgnoredPaths(rootPath, listed.map((entry) => entry.rel));
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
fetchAndroidRelease,
|
|
10
|
+
resetAndroidReleaseCache,
|
|
11
|
+
versionFromAssetName,
|
|
12
|
+
} from '@/server/lib/updates/android-release';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The asset name is the only place the APK's version exists: the release TAG
|
|
16
|
+
* names the SERVER version, and the two move independently. A parser that read
|
|
17
|
+
* the tag would compare a server version against an APK version.
|
|
18
|
+
*/
|
|
19
|
+
describe('versionFromAssetName', () => {
|
|
20
|
+
test('reads the version the release workflow writes', () => {
|
|
21
|
+
expect(versionFromAssetName('kabmo-android-v1.2.3.apk')).toBe('1.2.3');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('accepts a hand-uploaded name without the v', () => {
|
|
25
|
+
expect(versionFromAssetName('kabmo-android-1.2.3.apk')).toBe('1.2.3');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('ignores anything that is not the APK', () => {
|
|
29
|
+
expect(versionFromAssetName('kabmo-android-v1.2.3.apk.zip')).toBeNull();
|
|
30
|
+
expect(versionFromAssetName('source.tar.gz')).toBeNull();
|
|
31
|
+
expect(versionFromAssetName('kabmo-android-latest.apk')).toBeNull();
|
|
32
|
+
expect(versionFromAssetName('')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A fetch double that answers one body, and counts its calls. */
|
|
37
|
+
function stubFetch(body: unknown, ok = true) {
|
|
38
|
+
const calls: string[] = [];
|
|
39
|
+
const impl = (async (input: string | URL | Request) => {
|
|
40
|
+
calls.push(String(input));
|
|
41
|
+
return {
|
|
42
|
+
ok,
|
|
43
|
+
json: async () => body,
|
|
44
|
+
} as Response;
|
|
45
|
+
}) as unknown as typeof fetch;
|
|
46
|
+
return { impl, calls };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('fetchAndroidRelease', () => {
|
|
50
|
+
// The cache is process-wide (see `resetAndroidReleaseCache`), so every case
|
|
51
|
+
// starts from a cold one or it reads the previous case's answer.
|
|
52
|
+
beforeEach(() => resetAndroidReleaseCache());
|
|
53
|
+
|
|
54
|
+
test('picks the APK asset and its version', async () => {
|
|
55
|
+
const { impl } = stubFetch({
|
|
56
|
+
html_url: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
57
|
+
assets: [
|
|
58
|
+
{ name: 'source.tar.gz', browser_download_url: 'https://x/src.tgz' },
|
|
59
|
+
{
|
|
60
|
+
name: 'kabmo-android-v1.2.3.apk',
|
|
61
|
+
browser_download_url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const release = await fetchAndroidRelease(impl);
|
|
67
|
+
expect(release).toEqual({
|
|
68
|
+
url: 'https://x/kabmo-android-v1.2.3.apk',
|
|
69
|
+
latest: '1.2.3',
|
|
70
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v9.9.9',
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('answers null for a release with no APK', async () => {
|
|
75
|
+
const { impl } = stubFetch({ html_url: 'https://x', assets: [{ name: 'notes.md' }] });
|
|
76
|
+
const release = await fetchAndroidRelease(impl);
|
|
77
|
+
expect(release).toEqual({ url: null, latest: null, releaseUrl: 'https://x' });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('answers null when the probe fails', async () => {
|
|
81
|
+
// An offline box or a rate-limited GitHub must not take the sidebar down,
|
|
82
|
+
// and a failure must not be read as "no newer version" either — it is
|
|
83
|
+
// simply no offer.
|
|
84
|
+
const { impl } = stubFetch({}, false);
|
|
85
|
+
expect(await fetchAndroidRelease(impl)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('answers null when the body is malformed', async () => {
|
|
89
|
+
const { impl } = stubFetch({ assets: 'nope' });
|
|
90
|
+
expect(await fetchAndroidRelease(impl)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|