@makerbi/remodex 2.4.0 → 3.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/bin/remodex.js +25 -2
- package/package.json +1 -1
- package/src/bridge.js +46 -61
- package/src/codex-desktop-refresher.js +173 -8
- package/src/codex-tool-wrapper.js +523 -0
- package/src/desktop-ipc-action-follower.js +316 -59
- package/src/desktop-ipc-live-owner.js +148 -6
- package/src/desktop-ipc-owner-transport.js +143 -73
- package/src/desktop-ipc-shared.js +125 -8
- package/src/git-handler.js +189 -108
- package/src/index.js +2 -0
- package/src/macos-launch-agent.js +65 -34
- package/src/rollout-live-mirror.js +142 -13
- package/src/rollout-watch.js +176 -54
- package/src/session-jsonl-history.js +72 -32
- package/src/thread-list-provenance.js +105 -0
- package/src/thread-row-enrichment.js +43 -0
- package/src/thread-runtime-settings-store.js +3 -21
- package/src/worktree-origin.js +192 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// FILE: desktop-ipc-shared.js
|
|
2
|
-
// Purpose: Shared primitives for the Codex Desktop IPC modules (framing, socket path, JSON helpers).
|
|
2
|
+
// Purpose: Shared primitives for the Codex Desktop IPC modules (framing, socket path, envelopes, JSON helpers).
|
|
3
3
|
// Layer: CLI helper
|
|
4
|
-
// Exports: FRAME_HEADER_BYTES, MAX_FRAME_BYTES,
|
|
5
|
-
// Depends on: os, path
|
|
4
|
+
// Exports: CLIENT_STATUS_CHANGED, DESKTOP_IPC_METHOD_VERSIONS, FRAME_HEADER_BYTES, MAX_FRAME_BYTES, buildIpcRequestEnvelope, createFrameReader, resolveDefaultIpcSocketPath, resolveIpcSocketPathCandidates, toSocketPathResolver, writeFrame, plus JSON/text helpers
|
|
5
|
+
// Depends on: crypto, fs, os, path
|
|
6
6
|
|
|
7
|
+
const fs = require("fs");
|
|
7
8
|
const os = require("os");
|
|
8
9
|
const path = require("path");
|
|
9
10
|
const { createHash } = require("crypto");
|
|
@@ -21,16 +22,18 @@ const DESKTOP_IPC_METHOD_VERSIONS = new Map([
|
|
|
21
22
|
[CLIENT_STATUS_CHANGED, 1],
|
|
22
23
|
// Desktop pins thread-stream-state-changed at version 11 and drops mismatches.
|
|
23
24
|
["thread-stream-state-changed", 11],
|
|
25
|
+
["thread-stream-following-changed", 1],
|
|
26
|
+
["thread-stream-following-status-requested", 1],
|
|
24
27
|
["thread-archived", 2],
|
|
25
28
|
["thread-unarchived", 1],
|
|
26
|
-
["thread-read-state-changed",
|
|
29
|
+
["thread-read-state-changed", 2],
|
|
27
30
|
["thread-queued-followups-changed", 1],
|
|
28
31
|
["thread-follower-start-turn", 1],
|
|
29
32
|
["thread-follower-load-complete-history", 1],
|
|
30
33
|
["thread-follower-update-thread-settings", 1],
|
|
31
34
|
["thread-follower-compact-thread", 1],
|
|
32
35
|
["thread-follower-steer-turn", 1],
|
|
33
|
-
["thread-follower-interrupt-turn",
|
|
36
|
+
["thread-follower-interrupt-turn", 3],
|
|
34
37
|
["thread-follower-set-model-and-reasoning", 1],
|
|
35
38
|
["thread-follower-set-collaboration-mode", 1],
|
|
36
39
|
["thread-follower-edit-last-user-turn", 2],
|
|
@@ -517,13 +520,121 @@ function writeFrame(socket, payload, callback) {
|
|
|
517
520
|
socket.write(Buffer.concat([header, body]), callback);
|
|
518
521
|
}
|
|
519
522
|
|
|
520
|
-
|
|
523
|
+
// Codex moved its IPC bus into the Codex home directory; older desktop and CLI
|
|
524
|
+
// builds still expose it under the temp directory. Both are in the wild, so the
|
|
525
|
+
// bus is looked up in that order instead of being pinned to one location.
|
|
526
|
+
// Every participant on the bus — both clients and the fallback router's per-peer
|
|
527
|
+
// sockets — reads the same length-prefixed framing; only the dispatch differs.
|
|
528
|
+
// `onOverflow` owns the connection, so it decides how to drop a bogus peer.
|
|
529
|
+
function createFrameReader({ onFrame, onOverflow }) {
|
|
530
|
+
let buffer = Buffer.alloc(0);
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
push(chunk) {
|
|
534
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
535
|
+
while (buffer.length >= FRAME_HEADER_BYTES) {
|
|
536
|
+
const frameLength = buffer.readUInt32LE(0);
|
|
537
|
+
if (frameLength > MAX_FRAME_BYTES) {
|
|
538
|
+
buffer = Buffer.alloc(0);
|
|
539
|
+
onOverflow?.();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (buffer.length < FRAME_HEADER_BYTES + frameLength) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const payload = buffer
|
|
547
|
+
.slice(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + frameLength)
|
|
548
|
+
.toString("utf8");
|
|
549
|
+
buffer = buffer.slice(FRAME_HEADER_BYTES + frameLength);
|
|
550
|
+
const envelope = safeParseJSON(payload);
|
|
551
|
+
if (envelope) {
|
|
552
|
+
onFrame(envelope);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
reset() {
|
|
557
|
+
buffer = Buffer.alloc(0);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Desktop validates the method version and refuses requests from an unknown
|
|
563
|
+
// sender, so both clients must build this envelope the same way.
|
|
564
|
+
function buildIpcRequestEnvelope({ requestId, method, params, clientId, initializing = false }) {
|
|
565
|
+
return {
|
|
566
|
+
type: "request",
|
|
567
|
+
requestId,
|
|
568
|
+
sourceClientId: initializing ? "initializing-client" : clientId || "remodex-bridge",
|
|
569
|
+
version: DESKTOP_IPC_METHOD_VERSIONS.get(method) || 1,
|
|
570
|
+
method,
|
|
571
|
+
params: params || {},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Newer app-server builds omit every turn unless thread/read opts in explicitly.
|
|
576
|
+
// Internal hydration callers always need a complete baseline: publishing the
|
|
577
|
+
// metadata-only default as a Desktop snapshot would replace the visible history.
|
|
578
|
+
function buildCompleteThreadReadParams(threadId) {
|
|
579
|
+
return {
|
|
580
|
+
threadId: readString(threadId),
|
|
581
|
+
includeTurns: true,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function resolveIpcSocketPathCandidates() {
|
|
521
586
|
if (process.platform === "win32") {
|
|
522
|
-
return "\\\\.\\pipe\\codex-ipc";
|
|
587
|
+
return ["\\\\.\\pipe\\codex-ipc"];
|
|
523
588
|
}
|
|
524
589
|
|
|
590
|
+
const configuredCodexHome = readString(process.env.CODEX_HOME);
|
|
591
|
+
const codexHome = configuredCodexHome
|
|
592
|
+
? path.resolve(configuredCodexHome)
|
|
593
|
+
: path.join(os.homedir(), ".codex");
|
|
525
594
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
526
|
-
return
|
|
595
|
+
return [
|
|
596
|
+
path.join(codexHome, "ipc", "ipc.sock"),
|
|
597
|
+
path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`),
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Compatibility helper for callers that need one path synchronously. Transports
|
|
602
|
+
// use resolveIpcSocketPathCandidates instead because only a connection attempt
|
|
603
|
+
// can distinguish a live listener from a stale socket inode.
|
|
604
|
+
function resolveDefaultIpcSocketPath() {
|
|
605
|
+
const candidates = resolveIpcSocketPathCandidates();
|
|
606
|
+
for (const candidate of candidates) {
|
|
607
|
+
try {
|
|
608
|
+
if (fs.statSync(candidate).isSocket()) {
|
|
609
|
+
return candidate;
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
// Missing or unreadable candidate: keep looking.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return candidates[0];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// The bus location is only known at connect time, so transports accept either a
|
|
619
|
+
// fixed path or a resolver and always ask again before reconnecting.
|
|
620
|
+
function toSocketPathResolver(socketPath) {
|
|
621
|
+
return typeof socketPath === "function" ? socketPath : () => socketPath;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// A socket inode can outlive its listener. Clients therefore need the full
|
|
625
|
+
// ordered candidate list so they can try the legacy bus after a refused current
|
|
626
|
+
// socket instead of mistaking file existence for liveness.
|
|
627
|
+
function toSocketPathCandidatesResolver(socketPath) {
|
|
628
|
+
const resolveSocketPath = toSocketPathResolver(socketPath);
|
|
629
|
+
return () => {
|
|
630
|
+
const resolved = resolveSocketPath();
|
|
631
|
+
const candidates = Array.isArray(resolved) ? resolved : [resolved];
|
|
632
|
+
return candidates.filter((candidate, index) => (
|
|
633
|
+
typeof candidate === "string"
|
|
634
|
+
&& candidate.length > 0
|
|
635
|
+
&& candidates.indexOf(candidate) === index
|
|
636
|
+
));
|
|
637
|
+
};
|
|
527
638
|
}
|
|
528
639
|
|
|
529
640
|
// A source-neutral alias joins the same assistant prose when rollout events
|
|
@@ -556,7 +667,10 @@ function responseItemMessageText(payload) {
|
|
|
556
667
|
|
|
557
668
|
module.exports = {
|
|
558
669
|
CLIENT_STATUS_CHANGED,
|
|
670
|
+
buildCompleteThreadReadParams,
|
|
671
|
+
buildIpcRequestEnvelope,
|
|
559
672
|
buildRemodexSourceItemKey,
|
|
673
|
+
createFrameReader,
|
|
560
674
|
DESKTOP_IPC_METHOD_VERSIONS,
|
|
561
675
|
FRAME_HEADER_BYTES,
|
|
562
676
|
MAX_FRAME_BYTES,
|
|
@@ -574,9 +688,12 @@ module.exports = {
|
|
|
574
688
|
responseItemMessageText,
|
|
575
689
|
requestIdKey,
|
|
576
690
|
resolveDefaultIpcSocketPath,
|
|
691
|
+
resolveIpcSocketPathCandidates,
|
|
577
692
|
safeParseJSON,
|
|
578
693
|
sanitizeUserInputEntries,
|
|
579
694
|
sanitizeUserRoleItem,
|
|
695
|
+
toSocketPathCandidatesResolver,
|
|
696
|
+
toSocketPathResolver,
|
|
580
697
|
visibleUserPromptText,
|
|
581
698
|
visibleUserPromptFromInputEntries,
|
|
582
699
|
writeFrame,
|
package/src/git-handler.js
CHANGED
|
@@ -635,13 +635,11 @@ async function gitCreateBranch(cwd, params) {
|
|
|
635
635
|
return { branch: name, status };
|
|
636
636
|
}
|
|
637
637
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
await assertValidCreatedBranchName(cwd, branch);
|
|
644
|
-
|
|
638
|
+
// Resolves the shared creation context for both worktree flavors and enforces
|
|
639
|
+
// the invariants they have in common: a locally available base branch, and
|
|
640
|
+
// dirty changes that may only travel when the base branch is checked out.
|
|
641
|
+
// `destinationLabel` only shapes the dirty-transfer error message.
|
|
642
|
+
async function resolveWorktreeCreationContext(cwd, params, destinationLabel) {
|
|
645
643
|
const branchResult = await gitBranches(cwd);
|
|
646
644
|
const repoRoot = await resolveRepoRoot(cwd);
|
|
647
645
|
const status = await gitStatus(cwd);
|
|
@@ -666,34 +664,27 @@ async function gitCreateWorktree(cwd, params) {
|
|
|
666
664
|
const transferVerb = changeTransfer === "copy" ? "copy" : "move";
|
|
667
665
|
throw gitError(
|
|
668
666
|
"dirty_worktree_base_mismatch",
|
|
669
|
-
`Uncommitted changes can ${transferVerb} into
|
|
667
|
+
`Uncommitted changes can ${transferVerb} into ${destinationLabel} only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
|
|
670
668
|
);
|
|
671
669
|
}
|
|
672
670
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
branch,
|
|
684
|
-
worktreePath: existingWorktreePath,
|
|
685
|
-
alreadyExisted: true,
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
const branchExists = await localBranchExists(cwd, branch);
|
|
690
|
-
if (branchExists) {
|
|
691
|
-
throw gitError(
|
|
692
|
-
"branch_exists",
|
|
693
|
-
`Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
|
|
694
|
-
);
|
|
695
|
-
}
|
|
671
|
+
return {
|
|
672
|
+
branchResult,
|
|
673
|
+
repoRoot,
|
|
674
|
+
projectRelativePath,
|
|
675
|
+
changeScope,
|
|
676
|
+
baseBranch,
|
|
677
|
+
changeTransfer,
|
|
678
|
+
canCarryLocalChanges,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
696
681
|
|
|
682
|
+
// Shared creation core: allocates the managed path, carries local changes per
|
|
683
|
+
// the transfer mode, runs `git worktree add`, copies manifest files, and rolls
|
|
684
|
+
// everything back if any step after allocation fails. `branch` switches the
|
|
685
|
+
// checkout mode (`-b branch` vs `--detach`) and scopes cleanup/error mapping.
|
|
686
|
+
async function createWorktreeAtManagedPath(context, { branch = null, failureMessage }) {
|
|
687
|
+
const { repoRoot, changeScope, baseBranch, changeTransfer, canCarryLocalChanges } = context;
|
|
697
688
|
const worktreeRootPath = allocateManagedWorktreePath(repoRoot);
|
|
698
689
|
let handoffStashRef = null;
|
|
699
690
|
let copiedLocalChangesPatch = "";
|
|
@@ -708,7 +699,8 @@ async function gitCreateWorktree(cwd, params) {
|
|
|
708
699
|
}
|
|
709
700
|
}
|
|
710
701
|
|
|
711
|
-
|
|
702
|
+
const checkoutArgs = branch ? ["-b", branch] : ["--detach"];
|
|
703
|
+
await git(repoRoot, "worktree", "add", ...checkoutArgs, worktreeRootPath, baseBranch);
|
|
712
704
|
didCreateWorktree = true;
|
|
713
705
|
|
|
714
706
|
if (handoffStashRef) {
|
|
@@ -717,6 +709,7 @@ async function gitCreateWorktree(cwd, params) {
|
|
|
717
709
|
if (copiedLocalChangesPatch) {
|
|
718
710
|
await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
|
|
719
711
|
}
|
|
712
|
+
await copyWorktreeIncludeFiles(repoRoot, worktreeRootPath);
|
|
720
713
|
} catch (err) {
|
|
721
714
|
if (didCreateWorktree) {
|
|
722
715
|
await cleanupManagedWorktree(repoRoot, worktreeRootPath, branch);
|
|
@@ -731,102 +724,90 @@ async function gitCreateWorktree(cwd, params) {
|
|
|
731
724
|
if (err.message?.includes("invalid reference")) {
|
|
732
725
|
throw gitError("missing_base_branch", `Base branch '${baseBranch}' does not exist.`);
|
|
733
726
|
}
|
|
734
|
-
if (
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
727
|
+
if (branch) {
|
|
728
|
+
if (err.message?.includes("already exists")) {
|
|
729
|
+
throw gitError("branch_exists", `Branch '${branch}' already exists.`);
|
|
730
|
+
}
|
|
731
|
+
if (err.message?.includes("already used by worktree") || err.message?.includes("already checked out at")) {
|
|
732
|
+
throw gitError(
|
|
733
|
+
"branch_in_other_worktree",
|
|
734
|
+
`Branch '${branch}' is already open in another worktree.`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
742
737
|
}
|
|
743
|
-
throw gitError("create_worktree_failed", err.message ||
|
|
738
|
+
throw gitError("create_worktree_failed", err.message || failureMessage);
|
|
744
739
|
}
|
|
745
740
|
|
|
746
|
-
const worktreePath = scopedWorktreePath(worktreeRootPath, projectRelativePath);
|
|
747
741
|
return {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
alreadyExisted: false,
|
|
742
|
+
worktreeRootPath,
|
|
743
|
+
transferredChanges: Boolean(handoffStashRef || copiedLocalChangesPatch),
|
|
751
744
|
};
|
|
752
745
|
}
|
|
753
746
|
|
|
754
|
-
async function
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const projectRelativePath = resolveProjectRelativePath(cwd, repoRoot);
|
|
759
|
-
const changeScope = await scopedProjectChanges(repoRoot, projectRelativePath);
|
|
760
|
-
const baseBranch = resolveBaseBranchName(params.baseBranch, branchResult.defaultBranch);
|
|
761
|
-
const changeTransfer = resolveWorktreeChangeTransfer(params.changeTransfer);
|
|
762
|
-
if (!baseBranch) {
|
|
763
|
-
throw gitError("missing_base_branch", "Base branch is required.");
|
|
764
|
-
}
|
|
765
|
-
if (!(await localBranchExists(cwd, baseBranch))) {
|
|
766
|
-
throw gitError(
|
|
767
|
-
"missing_base_branch",
|
|
768
|
-
`Base branch '${baseBranch}' is not available locally. Create or check out that branch first.`
|
|
769
|
-
);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const currentBranch = typeof status.branch === "string" ? status.branch.trim() : "";
|
|
773
|
-
const canCarryLocalChanges = changeScope.dirty && !!currentBranch && currentBranch === baseBranch;
|
|
774
|
-
if (changeScope.dirty && changeTransfer !== "none" && !canCarryLocalChanges) {
|
|
775
|
-
const currentBranchLabel = currentBranch || "the current branch";
|
|
776
|
-
const transferVerb = changeTransfer === "copy" ? "copy" : "move";
|
|
777
|
-
throw gitError(
|
|
778
|
-
"dirty_worktree_base_mismatch",
|
|
779
|
-
`Uncommitted changes can ${transferVerb} into a managed worktree only from ${currentBranchLabel}. Switch the base branch to match or clean up local changes first.`
|
|
780
|
-
);
|
|
747
|
+
async function gitCreateWorktree(cwd, params) {
|
|
748
|
+
const branch = normalizeCreatedBranchName(params.name);
|
|
749
|
+
if (!branch) {
|
|
750
|
+
throw gitError("missing_branch_name", "Branch name is required.");
|
|
781
751
|
}
|
|
752
|
+
await assertValidCreatedBranchName(cwd, branch);
|
|
782
753
|
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
let copiedLocalChangesPatch = "";
|
|
786
|
-
let didCreateWorktree = false;
|
|
754
|
+
const context = await resolveWorktreeCreationContext(cwd, params, "a new worktree");
|
|
755
|
+
const { branchResult, repoRoot, projectRelativePath } = context;
|
|
787
756
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
757
|
+
const existingWorktreePath = branchResult.worktreePathByBranch[branch];
|
|
758
|
+
if (existingWorktreePath) {
|
|
759
|
+
if (sameFilePath(existingWorktreePath, cwd)) {
|
|
760
|
+
throw gitError(
|
|
761
|
+
"branch_already_open_here",
|
|
762
|
+
`Branch '${branch}' is already open in this project.`
|
|
763
|
+
);
|
|
795
764
|
}
|
|
796
765
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
if (
|
|
801
|
-
await
|
|
802
|
-
}
|
|
803
|
-
if (copiedLocalChangesPatch) {
|
|
804
|
-
await applyCopiedLocalChangesToWorktree(worktreeRootPath, copiedLocalChangesPatch);
|
|
805
|
-
}
|
|
806
|
-
} catch (err) {
|
|
807
|
-
if (didCreateWorktree) {
|
|
808
|
-
await cleanupManagedWorktree(repoRoot, worktreeRootPath);
|
|
809
|
-
} else {
|
|
810
|
-
fs.rmSync(path.dirname(worktreeRootPath), { recursive: true, force: true });
|
|
766
|
+
// Backfill manifest files a worktree created before `.worktreeinclude`
|
|
767
|
+
// existed never received; the copy skips files the worktree already has.
|
|
768
|
+
const existingWorktreeRoot = await resolveRepoRoot(existingWorktreePath).catch(() => null);
|
|
769
|
+
if (existingWorktreeRoot) {
|
|
770
|
+
await copyWorktreeIncludeFiles(repoRoot, existingWorktreeRoot);
|
|
811
771
|
}
|
|
812
772
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
773
|
+
return {
|
|
774
|
+
branch,
|
|
775
|
+
worktreePath: existingWorktreePath,
|
|
776
|
+
alreadyExisted: true,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
816
779
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
780
|
+
if (await localBranchExists(cwd, branch)) {
|
|
781
|
+
throw gitError(
|
|
782
|
+
"branch_exists",
|
|
783
|
+
`Branch '${branch}' already exists locally. Choose another name or open that branch instead.`
|
|
784
|
+
);
|
|
821
785
|
}
|
|
822
786
|
|
|
823
|
-
const
|
|
787
|
+
const { worktreeRootPath } = await createWorktreeAtManagedPath(context, {
|
|
788
|
+
branch,
|
|
789
|
+
failureMessage: "Failed to create worktree.",
|
|
790
|
+
});
|
|
791
|
+
|
|
824
792
|
return {
|
|
825
|
-
|
|
793
|
+
branch,
|
|
794
|
+
worktreePath: scopedWorktreePath(worktreeRootPath, projectRelativePath),
|
|
826
795
|
alreadyExisted: false,
|
|
827
|
-
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function gitCreateManagedWorktree(cwd, params) {
|
|
800
|
+
const context = await resolveWorktreeCreationContext(cwd, params, "a managed worktree");
|
|
801
|
+
const { worktreeRootPath, transferredChanges } = await createWorktreeAtManagedPath(context, {
|
|
802
|
+
failureMessage: "Failed to create managed worktree.",
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
worktreePath: scopedWorktreePath(worktreeRootPath, context.projectRelativePath),
|
|
807
|
+
alreadyExisted: false,
|
|
808
|
+
baseBranch: context.baseBranch,
|
|
828
809
|
headMode: "detached",
|
|
829
|
-
transferredChanges
|
|
810
|
+
transferredChanges,
|
|
830
811
|
};
|
|
831
812
|
}
|
|
832
813
|
|
|
@@ -2005,6 +1986,106 @@ async function rollbackFailedHandoffTransfer(cwd, pathspecArgs = []) {
|
|
|
2005
1986
|
}
|
|
2006
1987
|
}
|
|
2007
1988
|
|
|
1989
|
+
// Mirrors Codex Desktop's `.worktreeinclude`: files Git leaves behind (like an
|
|
1990
|
+
// ignored `.env`) listed in a repository-root manifest are copied into worktrees.
|
|
1991
|
+
// Best effort by design — a stale manifest entry must never fail creation.
|
|
1992
|
+
const WORKTREE_INCLUDE_FILE = ".worktreeinclude";
|
|
1993
|
+
const WORKTREE_INCLUDE_MAX_FILES = 512;
|
|
1994
|
+
|
|
1995
|
+
async function copyWorktreeIncludeFiles(repoRoot, worktreeRootPath) {
|
|
1996
|
+
const manifestPath = path.join(repoRoot, WORKTREE_INCLUDE_FILE);
|
|
1997
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const relativePaths = await listWorktreeIncludePaths(repoRoot, manifestPath);
|
|
2002
|
+
if (relativePaths.length === 0) {
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
const canonicalWorktreeRoot = await fs.promises.realpath(worktreeRootPath).catch(() => null);
|
|
2007
|
+
if (!canonicalWorktreeRoot) {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
for (const relativePath of relativePaths) {
|
|
2012
|
+
const sourcePath = path.resolve(repoRoot, relativePath);
|
|
2013
|
+
const destinationPath = path.resolve(worktreeRootPath, relativePath);
|
|
2014
|
+
// `ls-files` output is repo-relative, but keep both sides pinned to their
|
|
2015
|
+
// roots so a hostile manifest entry cannot escape either tree.
|
|
2016
|
+
if (!isPathContainedIn(sourcePath, repoRoot) || !isPathContainedIn(destinationPath, worktreeRootPath)) {
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
await copyWorktreeIncludeEntry(sourcePath, destinationPath, canonicalWorktreeRoot);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Resolves the manifest to repo-relative untracked paths. The manifest uses
|
|
2024
|
+
// gitignore syntax, so let git itself do the matching: `--others -i
|
|
2025
|
+
// --exclude-from` lists every untracked file (ignored or not) that the
|
|
2026
|
+
// manifest patterns select, with full gitignore semantics — nested `.env`
|
|
2027
|
+
// matches, anchored `/foo` and `!negations` behave as documented, and a
|
|
2028
|
+
// malformed line cannot abort the listing the way a bad pathspec would.
|
|
2029
|
+
async function listWorktreeIncludePaths(repoRoot, manifestPath) {
|
|
2030
|
+
let listing = "";
|
|
2031
|
+
try {
|
|
2032
|
+
listing = await git(
|
|
2033
|
+
repoRoot,
|
|
2034
|
+
"ls-files",
|
|
2035
|
+
"--others",
|
|
2036
|
+
"-i",
|
|
2037
|
+
"-z",
|
|
2038
|
+
`--exclude-from=${manifestPath}`
|
|
2039
|
+
);
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
console.error(`[remodex] .worktreeinclude listing failed: ${err.message}`);
|
|
2042
|
+
return [];
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
const relativePaths = listing.split("\0").filter(Boolean);
|
|
2046
|
+
if (relativePaths.length > WORKTREE_INCLUDE_MAX_FILES) {
|
|
2047
|
+
console.error(
|
|
2048
|
+
`[remodex] .worktreeinclude matched ${relativePaths.length} files; copying only the first ${WORKTREE_INCLUDE_MAX_FILES}. Narrow the manifest patterns.`
|
|
2049
|
+
);
|
|
2050
|
+
relativePaths.length = WORKTREE_INCLUDE_MAX_FILES;
|
|
2051
|
+
}
|
|
2052
|
+
return relativePaths;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Copies one manifest match into the worktree, skipping anything unsafe or
|
|
2056
|
+
// already present rather than failing the surrounding creation flow.
|
|
2057
|
+
async function copyWorktreeIncludeEntry(sourcePath, destinationPath, canonicalWorktreeRoot) {
|
|
2058
|
+
try {
|
|
2059
|
+
// Regular files only: copying through a symlinked source would smuggle
|
|
2060
|
+
// out-of-repo content (e.g. `.env -> ~/.ssh/key`) into every worktree.
|
|
2061
|
+
const sourceStats = await fs.promises.lstat(sourcePath);
|
|
2062
|
+
if (!sourceStats.isFile()) {
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
const destinationDirectory = path.dirname(destinationPath);
|
|
2066
|
+
await fs.promises.mkdir(destinationDirectory, { recursive: true });
|
|
2067
|
+
// Re-check containment on the real path: a checked-out symlinked parent
|
|
2068
|
+
// directory would otherwise let the copy write outside the worktree.
|
|
2069
|
+
const canonicalDestinationDirectory = await fs.promises.realpath(destinationDirectory);
|
|
2070
|
+
if (!isPathContainedIn(canonicalDestinationDirectory, canonicalWorktreeRoot)) {
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
// COPYFILE_EXCL keeps existing files intact: change transfer may already
|
|
2074
|
+
// have carried a dirty copy, and reused worktrees keep their own state.
|
|
2075
|
+
await fs.promises.copyFile(
|
|
2076
|
+
sourcePath,
|
|
2077
|
+
path.join(canonicalDestinationDirectory, path.basename(destinationPath)),
|
|
2078
|
+
fs.constants.COPYFILE_EXCL
|
|
2079
|
+
);
|
|
2080
|
+
} catch {
|
|
2081
|
+
// Skip unreadable or already-present entries; the worktree stays usable.
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function isPathContainedIn(candidatePath, rootPath) {
|
|
2086
|
+
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2008
2089
|
async function cleanupManagedWorktree(repoRoot, worktreeRootPath, branchName = null) {
|
|
2009
2090
|
try {
|
|
2010
2091
|
await git(repoRoot, "worktree", "remove", "--force", worktreeRootPath);
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
runMacOSBridgeService,
|
|
19
19
|
startMacOSBridgeService,
|
|
20
20
|
stopMacOSBridgeService,
|
|
21
|
+
uninstallMacOSBridgeService,
|
|
21
22
|
} = require("./macos-launch-agent");
|
|
22
23
|
|
|
23
24
|
module.exports = {
|
|
@@ -32,6 +33,7 @@ module.exports = {
|
|
|
32
33
|
runMacOSBridgeService,
|
|
33
34
|
startMacOSBridgeService,
|
|
34
35
|
stopMacOSBridgeService,
|
|
36
|
+
uninstallMacOSBridgeService,
|
|
35
37
|
resetBridgePairing: resetBridgeTrustState,
|
|
36
38
|
openLastActiveThread,
|
|
37
39
|
watchThreadRollout,
|