flowviant 0.67.0 → 0.68.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/bin/lib/shipMerge.mjs +132 -0
- package/bin/lib/work.mjs +18 -29
- package/package.json +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { baseBranchName } from './git.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CARRY A SHIPPED TIP OUT ONTO BASE AND PUSH IT.
|
|
5
|
+
*
|
|
6
|
+
* Through a THROWAWAY DETACHED WORKTREE, which is the whole shape of this: the
|
|
7
|
+
* merge commit has to be made somewhere, and making it in anybody's checkout
|
|
8
|
+
* moves a directory somebody is working in. A detached worktree at base is
|
|
9
|
+
* nobody's, so the merge lands, the push goes, and the directory dies in the
|
|
10
|
+
* `finally` — on success, on conflict, on throw. It must die, or the next ship
|
|
11
|
+
* of this session trips over its corpse.
|
|
12
|
+
*
|
|
13
|
+
* `--no-ff`, NEVER squash: every delivered card carries commit shas as its
|
|
14
|
+
* receipts, and squashing would point all of them at commits that no longer
|
|
15
|
+
* exist on base.
|
|
16
|
+
*
|
|
17
|
+
* Two behaviours beyond that, and both arrived with per-person worktrees.
|
|
18
|
+
*/
|
|
19
|
+
export function mergeOutward({
|
|
20
|
+
tip,
|
|
21
|
+
count,
|
|
22
|
+
branch,
|
|
23
|
+
label,
|
|
24
|
+
git,
|
|
25
|
+
gitMerge,
|
|
26
|
+
repoRoot,
|
|
27
|
+
tmpDir,
|
|
28
|
+
baseRef,
|
|
29
|
+
workingTree,
|
|
30
|
+
warn,
|
|
31
|
+
}) {
|
|
32
|
+
const dropTmp = () => {
|
|
33
|
+
try {
|
|
34
|
+
git(['worktree', 'remove', '--force', tmpDir], repoRoot);
|
|
35
|
+
} catch {
|
|
36
|
+
/* not there — fine */
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const attempt = () => {
|
|
40
|
+
dropTmp();
|
|
41
|
+
git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
|
|
42
|
+
gitMerge(
|
|
43
|
+
['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
|
|
44
|
+
tmpDir
|
|
45
|
+
);
|
|
46
|
+
git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmpDir);
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
try {
|
|
50
|
+
attempt();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
/**
|
|
53
|
+
* TWO PEOPLE SHIPPED AT ONCE — retry exactly once.
|
|
54
|
+
*
|
|
55
|
+
* Ship takes a write lock on a PLACE, and since every person works in a
|
|
56
|
+
* directory of their own, two teammates shipping hold two DIFFERENT
|
|
57
|
+
* locks and nothing serializes them. Both fetch, both merge onto the same
|
|
58
|
+
* base in their own throwaway, and whoever pushes second is rejected
|
|
59
|
+
* non-fast-forward. The window is fetch-to-push, and it did not exist
|
|
60
|
+
* while everyone shared one directory — it arrived with the split.
|
|
61
|
+
*
|
|
62
|
+
* WITHOUT THIS the loser is told their ship FAILED, in raw git, over a
|
|
63
|
+
* race that resolves itself by looking again. That breaks the promise
|
|
64
|
+
* this path exists to keep: nobody may be left believing their work is
|
|
65
|
+
* or is not on base when the opposite is true.
|
|
66
|
+
*
|
|
67
|
+
* ONCE, not a loop. A second rejection is no longer a race — it is a repo
|
|
68
|
+
* something else is writing to continuously, and the honest answer there
|
|
69
|
+
* is the error. The retry re-fetches and rebuilds the throwaway from the
|
|
70
|
+
* NEW base, so it merges against what the winner just landed rather than
|
|
71
|
+
* re-pushing a stale merge. The TIP is untouched, so the receipts still
|
|
72
|
+
* name exactly the same commits.
|
|
73
|
+
*/
|
|
74
|
+
if (!isRaceRejection(e)) throw e;
|
|
75
|
+
warn?.('ship: base moved under us — refetching and merging again');
|
|
76
|
+
try {
|
|
77
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
78
|
+
} catch {
|
|
79
|
+
/* offline — the retry fails honestly on the same push */
|
|
80
|
+
}
|
|
81
|
+
attempt();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* AND BRING THE SHIP PLACE'S OWN BRANCH UP, when that branch IS base.
|
|
85
|
+
*
|
|
86
|
+
* Which is every tab belonging to the machine's OPERATOR: their place is
|
|
87
|
+
* the project folder, and it sits on main. Without this, main is left one
|
|
88
|
+
* commit behind `origin/main` the instant it ships, because the `--no-ff`
|
|
89
|
+
* merge exists only on the remote — and `worktreeDiff` computes `behind` as
|
|
90
|
+
* `HEAD..origin/main` without filtering merges, so the rail immediately
|
|
91
|
+
* reported "1 new on main since you branched" and listed the operator's OWN
|
|
92
|
+
* ship commit back to them. That inverts the entire point of that block,
|
|
93
|
+
* which is to show the one thing a session cannot see from inside itself.
|
|
94
|
+
*
|
|
95
|
+
* Safe by construction and never a surprise: ship already required a clean
|
|
96
|
+
* tree, the fold already moved this same directory under the same exclusive
|
|
97
|
+
* lock, and `--ff-only` can neither conflict nor write a commit.
|
|
98
|
+
*
|
|
99
|
+
* NOBODY ELSE'S CHECKOUT MOVES. A teammate's branch is not base, so this
|
|
100
|
+
* does nothing for them — and their branch being genuinely behind base is a
|
|
101
|
+
* fact the rail should keep telling them.
|
|
102
|
+
*/
|
|
103
|
+
if (workingTree && branch && branch === baseBranchName(baseRef())) {
|
|
104
|
+
try {
|
|
105
|
+
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
106
|
+
git(['merge', '--ff-only', baseRef()], workingTree);
|
|
107
|
+
} catch {
|
|
108
|
+
/* a readout, not the ship — the merge already landed, and the next
|
|
109
|
+
fold picks this up either way */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
try {
|
|
114
|
+
dropTmp();
|
|
115
|
+
git(['worktree', 'prune'], repoRoot);
|
|
116
|
+
} catch {
|
|
117
|
+
/* best effort */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Is this push failure a LOST RACE rather than a broken repo?
|
|
124
|
+
*
|
|
125
|
+
* Matched on git's own words. Deliberately narrow: anything unrecognised is
|
|
126
|
+
* rethrown, because retrying an unknown failure is how a real problem gets
|
|
127
|
+
* reported twice and understood never.
|
|
128
|
+
*/
|
|
129
|
+
export function isRaceRejection(e) {
|
|
130
|
+
const d = `${e?.stdout ?? ''}\n${e?.stderr ?? ''}\n${e?.message ?? ''}`;
|
|
131
|
+
return /non-fast-forward|\[rejected\]|fetch first|stale info/i.test(d);
|
|
132
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
|
43
43
|
import { processesInGroups, liveGroups, processesSupported } from './processes.mjs';
|
|
44
44
|
import { createPlaceLock } from './placeLock.mjs';
|
|
45
45
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
46
|
+
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
46
47
|
import { openTunnel } from './preview.mjs';
|
|
47
48
|
import { c, note, ok, warn } from './ui.mjs';
|
|
48
49
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
@@ -2269,35 +2270,23 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
2269
2270
|
// Merge outward through a throwaway worktree so no checkout moves.
|
|
2270
2271
|
// The throwaway dies on EVERY exit — success, conflict or throw —
|
|
2271
2272
|
// or the next ship of this session trips over its corpse.
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
tmp
|
|
2290
|
-
);
|
|
2291
|
-
git(['push', 'origin', `HEAD:${baseBranchName(baseRef())}`], tmp);
|
|
2292
|
-
} finally {
|
|
2293
|
-
try {
|
|
2294
|
-
git(['worktree', 'remove', '--force', tmp], repoRoot);
|
|
2295
|
-
git(['worktree', 'prune'], repoRoot);
|
|
2296
|
-
} catch {
|
|
2297
|
-
/* best effort */
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
};
|
|
2273
|
+
// Carry the tip out onto base and push it. See `shipMerge.mjs` for
|
|
2274
|
+
// the throwaway-worktree shape, the one retry when two people ship at
|
|
2275
|
+
// once, and why the operator's own branch is fast-forwarded after.
|
|
2276
|
+
const mergeOutward = (tip, count) =>
|
|
2277
|
+
shipMergeOutward({
|
|
2278
|
+
tip,
|
|
2279
|
+
count,
|
|
2280
|
+
branch,
|
|
2281
|
+
label: job.sessionName || job.sessionId.slice(0, 8),
|
|
2282
|
+
git,
|
|
2283
|
+
gitMerge,
|
|
2284
|
+
repoRoot,
|
|
2285
|
+
tmpDir: join(baseDir, 'ship', job.sessionId),
|
|
2286
|
+
baseRef,
|
|
2287
|
+
workingTree: placeWtFor(shipPlace)?.wt ?? null,
|
|
2288
|
+
warn,
|
|
2289
|
+
});
|
|
2301
2290
|
// Idempotency: base already contains the branch tip. A re-offered
|
|
2302
2291
|
// job after a lost report lands here — never a re-merge, and never
|
|
2303
2292
|
// "nothing to ship" AS A FAILURE for work that in fact shipped. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|