worktree-compose 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/README.md +618 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/commands/clean.d.ts +1 -0
- package/dist/commands/clean.js +49 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +71 -0
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +35 -0
- package/dist/commands/restart.d.ts +1 -0
- package/dist/commands/restart.js +6 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +37 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +24 -0
- package/dist/compose/detect.d.ts +3 -0
- package/dist/compose/detect.js +25 -0
- package/dist/compose/parse.d.ts +2 -0
- package/dist/compose/parse.js +55 -0
- package/dist/compose/types.d.ts +13 -0
- package/dist/compose/types.js +1 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +16 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.js +29 -0
- package/dist/git/promote.d.ts +4 -0
- package/dist/git/promote.js +58 -0
- package/dist/git/worktree.d.ts +10 -0
- package/dist/git/worktree.js +40 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +132 -0
- package/dist/ports/allocate.d.ts +3 -0
- package/dist/ports/allocate.js +29 -0
- package/dist/ports/extract.d.ts +4 -0
- package/dist/ports/extract.js +92 -0
- package/dist/ports/types.d.ts +13 -0
- package/dist/ports/types.js +1 -0
- package/dist/sync/env.d.ts +5 -0
- package/dist/sync/env.js +58 -0
- package/dist/sync/files.d.ts +2 -0
- package/dist/sync/files.js +59 -0
- package/dist/utils/exec.d.ts +4 -0
- package/dist/utils/exec.js +23 -0
- package/dist/utils/log.d.ts +5 -0
- package/dist/utils/log.js +16 -0
- package/dist/utils/sanitize.d.ts +2 -0
- package/dist/utils/sanitize.js +11 -0
- package/package.json +49 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getRepoRoot, getRepoName } from "../compose/detect.js";
|
|
2
|
+
import { getNonMainWorktrees } from "../git/worktree.js";
|
|
3
|
+
import { composeProjectName } from "../utils/sanitize.js";
|
|
4
|
+
import { exec, execSafe } from "../utils/exec.js";
|
|
5
|
+
import * as log from "../utils/log.js";
|
|
6
|
+
export function cleanCommand() {
|
|
7
|
+
const repoRoot = getRepoRoot();
|
|
8
|
+
const repoName = getRepoName(repoRoot);
|
|
9
|
+
const worktrees = getNonMainWorktrees(repoRoot);
|
|
10
|
+
const currentWt = execSafe("git rev-parse --show-toplevel") ?? repoRoot;
|
|
11
|
+
for (let i = 0; i < worktrees.length; i++) {
|
|
12
|
+
const wt = worktrees[i];
|
|
13
|
+
const idx = i + 1;
|
|
14
|
+
const project = composeProjectName(repoName, idx, wt.branch);
|
|
15
|
+
log.info(`Stopping containers for ${project}...`);
|
|
16
|
+
try {
|
|
17
|
+
exec(`docker compose -p "${project}" down`, { cwd: wt.path });
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// already stopped
|
|
21
|
+
}
|
|
22
|
+
if (wt.path === currentWt) {
|
|
23
|
+
log.warn(`Skipping removal of current worktree: ${wt.path}`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
log.info(`Removing worktree: ${wt.path}`);
|
|
27
|
+
try {
|
|
28
|
+
exec(`git -C "${repoRoot}" worktree remove "${wt.path}" --force`);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
log.warn(`Could not remove ${wt.path}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
execSafe(`git -C "${repoRoot}" worktree prune`);
|
|
35
|
+
const staleContainers = execSafe(`docker ps -aq --filter "label=com.docker.compose.project" --filter "name=-wt-"`);
|
|
36
|
+
if (staleContainers) {
|
|
37
|
+
log.info("Removing stale worktree containers...");
|
|
38
|
+
execSafe(`docker rm -f ${staleContainers}`);
|
|
39
|
+
}
|
|
40
|
+
const staleNetworks = execSafe(`docker network ls -q --filter "name=${repoName}-wt-"`);
|
|
41
|
+
if (staleNetworks) {
|
|
42
|
+
execSafe(`docker network rm ${staleNetworks}`);
|
|
43
|
+
}
|
|
44
|
+
const staleVolumes = execSafe(`docker volume ls -q --filter "name=${repoName}-wt-"`);
|
|
45
|
+
if (staleVolumes) {
|
|
46
|
+
execSafe(`docker volume rm ${staleVolumes}`);
|
|
47
|
+
}
|
|
48
|
+
log.success("Cleanup complete.");
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listCommand(): void;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Table from "cli-table3";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { buildContext } from "../context.js";
|
|
4
|
+
import { allocateWorktreePorts } from "../ports/allocate.js";
|
|
5
|
+
import { composeProjectName } from "../utils/sanitize.js";
|
|
6
|
+
import { execSafe } from "../utils/exec.js";
|
|
7
|
+
import { warn } from "../utils/log.js";
|
|
8
|
+
import { suggestEnvVar } from "../ports/extract.js";
|
|
9
|
+
function isWorktreeUp(projectName, wtPath) {
|
|
10
|
+
const result = execSafe(`docker compose -p "${projectName}" ps --format json`, { cwd: wtPath });
|
|
11
|
+
return result !== null && result.length > 2;
|
|
12
|
+
}
|
|
13
|
+
export function listCommand() {
|
|
14
|
+
const ctx = buildContext();
|
|
15
|
+
const rawPorts = ctx.portMappings.filter((m) => m.envVar === null);
|
|
16
|
+
for (const p of rawPorts) {
|
|
17
|
+
warn(`Service "${p.serviceName}" uses a raw port mapping (${p.raw}). ` +
|
|
18
|
+
`To enable port isolation, change it to: "\${${suggestEnvVar(p.serviceName)}:-${p.defaultPort}}:${p.containerPort}"`);
|
|
19
|
+
}
|
|
20
|
+
if (ctx.worktrees.length === 0) {
|
|
21
|
+
console.log("\nNo extra worktrees found. Create one with:\n git worktree add ../my-branch my-branch\n");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const table = new Table({
|
|
25
|
+
head: [
|
|
26
|
+
chalk.white("Index"),
|
|
27
|
+
chalk.white("Branch"),
|
|
28
|
+
chalk.white("Status"),
|
|
29
|
+
chalk.white("URL"),
|
|
30
|
+
chalk.white("Ports"),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
const overridable = ctx.portMappings.filter((m) => m.envVar !== null);
|
|
34
|
+
const defaultPorts = overridable
|
|
35
|
+
.map((m) => `${m.serviceName}:${m.defaultPort}`)
|
|
36
|
+
.join(" ");
|
|
37
|
+
table.push([
|
|
38
|
+
chalk.dim("-"),
|
|
39
|
+
chalk.dim("main"),
|
|
40
|
+
chalk.dim("-"),
|
|
41
|
+
chalk.dim("-"),
|
|
42
|
+
chalk.dim(defaultPorts),
|
|
43
|
+
]);
|
|
44
|
+
for (let i = 0; i < ctx.worktrees.length; i++) {
|
|
45
|
+
const wt = ctx.worktrees[i];
|
|
46
|
+
const idx = i + 1;
|
|
47
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
48
|
+
const allocations = allocateWorktreePorts(ctx.portMappings, idx);
|
|
49
|
+
const up = isWorktreeUp(project, wt.path);
|
|
50
|
+
const ports = allocations
|
|
51
|
+
.map((a) => `${a.serviceName}:${a.port}`)
|
|
52
|
+
.join(" ");
|
|
53
|
+
const frontendAlloc = allocations.find((a) => a.serviceName.includes("frontend") ||
|
|
54
|
+
a.serviceName.includes("web") ||
|
|
55
|
+
a.serviceName.includes("app") ||
|
|
56
|
+
a.serviceName.includes("ui"));
|
|
57
|
+
const url = frontendAlloc
|
|
58
|
+
? `http://localhost:${frontendAlloc.port}`
|
|
59
|
+
: allocations.length > 0
|
|
60
|
+
? `http://localhost:${allocations[allocations.length - 1].port}`
|
|
61
|
+
: "-";
|
|
62
|
+
table.push([
|
|
63
|
+
String(idx),
|
|
64
|
+
wt.branch,
|
|
65
|
+
up ? chalk.green("up") : chalk.red("down"),
|
|
66
|
+
up ? chalk.underline(url) : chalk.dim(url),
|
|
67
|
+
ports,
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
console.log(table.toString());
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function promoteCommand(index: number): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getRepoRoot } from "../compose/detect.js";
|
|
2
|
+
import { getWorktreeByIndex, getWorktreeBranch, } from "../git/worktree.js";
|
|
3
|
+
import { getChangedFiles, getLocalDirtyFiles, findConflicts, promoteFiles, } from "../git/promote.js";
|
|
4
|
+
import * as log from "../utils/log.js";
|
|
5
|
+
export function promoteCommand(index) {
|
|
6
|
+
const repoRoot = getRepoRoot();
|
|
7
|
+
const wt = getWorktreeByIndex(repoRoot, index);
|
|
8
|
+
if (!wt) {
|
|
9
|
+
log.error(`Worktree index ${index} not found. Run 'wtc list' to see available worktrees.`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const currentBranch = getWorktreeBranch(repoRoot);
|
|
13
|
+
const displayCurrent = currentBranch === "HEAD" ? "detached HEAD" : currentBranch;
|
|
14
|
+
log.info(`Promoting worktree ${index} (${wt.branch}) into ${displayCurrent}`);
|
|
15
|
+
const files = getChangedFiles(repoRoot, wt.path, currentBranch, wt.branch);
|
|
16
|
+
if (files.length === 0) {
|
|
17
|
+
log.info("No changes to promote.");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const dirtyFiles = getLocalDirtyFiles(repoRoot);
|
|
21
|
+
const conflicts = findConflicts(files, dirtyFiles);
|
|
22
|
+
if (conflicts.length > 0) {
|
|
23
|
+
log.error("Abort: the following files have uncommitted changes and would be overwritten:");
|
|
24
|
+
for (const f of conflicts) {
|
|
25
|
+
console.log(` ${f}`);
|
|
26
|
+
}
|
|
27
|
+
console.log("\nCommit or stash your local changes first, then re-run promote.");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
promoteFiles(repoRoot, wt.path, files);
|
|
31
|
+
log.success(`Promoted ${files.length} file(s). Changes are uncommitted in ${currentBranch}.`);
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
console.log(` ${f}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function restartCommand(indices: number[]): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startCommand(indices: number[]): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { buildContext, filterWorktrees } from "../context.js";
|
|
2
|
+
import { allocateWorktreePorts } from "../ports/allocate.js";
|
|
3
|
+
import { composeProjectName } from "../utils/sanitize.js";
|
|
4
|
+
import { execLive } from "../utils/exec.js";
|
|
5
|
+
import { syncWorktreeFiles } from "../sync/files.js";
|
|
6
|
+
import { copyBaseEnv, injectPortOverrides } from "../sync/env.js";
|
|
7
|
+
import * as log from "../utils/log.js";
|
|
8
|
+
import { listCommand } from "./list.js";
|
|
9
|
+
export function startCommand(indices) {
|
|
10
|
+
const ctx = buildContext();
|
|
11
|
+
if (ctx.worktrees.length === 0) {
|
|
12
|
+
log.warn("No extra worktrees found. Create one with:\n git worktree add ../my-branch my-branch");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const targets = filterWorktrees(ctx.worktrees, indices);
|
|
16
|
+
for (const wt of targets) {
|
|
17
|
+
const idx = ctx.worktrees.indexOf(wt) + 1;
|
|
18
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
19
|
+
const allocations = allocateWorktreePorts(ctx.portMappings, idx);
|
|
20
|
+
log.header(`Worktree ${idx}: ${wt.branch}`);
|
|
21
|
+
log.info(`Path: ${wt.path}`);
|
|
22
|
+
log.info(`Project: ${project}`);
|
|
23
|
+
log.info(`Ports: ${allocations.map((a) => `${a.envVar}=${a.port}`).join(" ")}`);
|
|
24
|
+
syncWorktreeFiles(ctx.repoRoot, wt.path, ctx.composeFile, ctx.config.sync);
|
|
25
|
+
log.success("Synced infrastructure files");
|
|
26
|
+
copyBaseEnv(ctx.repoRoot, wt.path);
|
|
27
|
+
injectPortOverrides(`${wt.path}/.env`, allocations, ctx.config.envOverrides);
|
|
28
|
+
log.success("Injected port overrides into .env");
|
|
29
|
+
execLive(`docker compose -p "${project}" up -d --build`, {
|
|
30
|
+
cwd: wt.path,
|
|
31
|
+
env: { ...process.env, COMPOSE_PROJECT_NAME: project },
|
|
32
|
+
});
|
|
33
|
+
log.success(`Worktree ${idx} started`);
|
|
34
|
+
}
|
|
35
|
+
console.log("");
|
|
36
|
+
listCommand();
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stopCommand(indices: number[]): void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { buildContext, filterWorktrees } from "../context.js";
|
|
2
|
+
import { composeProjectName } from "../utils/sanitize.js";
|
|
3
|
+
import { execLive } from "../utils/exec.js";
|
|
4
|
+
import * as log from "../utils/log.js";
|
|
5
|
+
export function stopCommand(indices) {
|
|
6
|
+
const ctx = buildContext();
|
|
7
|
+
if (ctx.worktrees.length === 0) {
|
|
8
|
+
log.warn("No worktrees to stop.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const targets = filterWorktrees(ctx.worktrees, indices);
|
|
12
|
+
for (const wt of targets) {
|
|
13
|
+
const idx = ctx.worktrees.indexOf(wt) + 1;
|
|
14
|
+
const project = composeProjectName(ctx.repoName, idx, wt.branch);
|
|
15
|
+
log.info(`Stopping ${project}...`);
|
|
16
|
+
try {
|
|
17
|
+
execLive(`docker compose -p "${project}" down`, { cwd: wt.path });
|
|
18
|
+
log.success(`Stopped worktree ${idx} (${wt.branch})`);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
log.warn(`Could not stop ${project} (may already be stopped)`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec } from "../utils/exec.js";
|
|
4
|
+
const COMPOSE_FILENAMES = [
|
|
5
|
+
"compose.yaml",
|
|
6
|
+
"compose.yml",
|
|
7
|
+
"docker-compose.yaml",
|
|
8
|
+
"docker-compose.yml",
|
|
9
|
+
];
|
|
10
|
+
export function getRepoRoot(cwd) {
|
|
11
|
+
return exec("git rev-parse --show-toplevel", {
|
|
12
|
+
cwd: cwd ?? process.cwd(),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function getRepoName(repoRoot) {
|
|
16
|
+
return path.basename(repoRoot);
|
|
17
|
+
}
|
|
18
|
+
export function detectComposeFile(repoRoot) {
|
|
19
|
+
for (const name of COMPOSE_FILENAMES) {
|
|
20
|
+
const full = path.join(repoRoot, name);
|
|
21
|
+
if (fs.existsSync(full))
|
|
22
|
+
return full;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
function parseServicePorts(ports) {
|
|
4
|
+
if (!Array.isArray(ports))
|
|
5
|
+
return [];
|
|
6
|
+
return ports.flatMap((p) => {
|
|
7
|
+
if (typeof p === "string")
|
|
8
|
+
return [p];
|
|
9
|
+
if (typeof p === "number")
|
|
10
|
+
return [String(p)];
|
|
11
|
+
if (typeof p === "object" && p !== null) {
|
|
12
|
+
const obj = p;
|
|
13
|
+
if (obj.published != null && obj.target != null) {
|
|
14
|
+
return [`${obj.published}:${obj.target}`];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return [];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function parseServiceBuild(build) {
|
|
21
|
+
if (typeof build === "string") {
|
|
22
|
+
return { context: build };
|
|
23
|
+
}
|
|
24
|
+
if (typeof build === "object" && build !== null) {
|
|
25
|
+
const obj = build;
|
|
26
|
+
return {
|
|
27
|
+
context: typeof obj.context === "string" ? obj.context : undefined,
|
|
28
|
+
dockerfile: typeof obj.dockerfile === "string" ? obj.dockerfile : undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function parseEnvFile(envFile) {
|
|
34
|
+
if (typeof envFile === "string")
|
|
35
|
+
return [envFile];
|
|
36
|
+
if (Array.isArray(envFile)) {
|
|
37
|
+
return envFile.filter((e) => typeof e === "string");
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
export function parseComposeFile(composePath) {
|
|
42
|
+
const raw = fs.readFileSync(composePath, "utf-8");
|
|
43
|
+
const doc = parseYaml(raw, { uniqueKeys: false });
|
|
44
|
+
if (!doc || typeof doc !== "object" || !("services" in doc)) {
|
|
45
|
+
return { services: [], composePath };
|
|
46
|
+
}
|
|
47
|
+
const servicesObj = doc.services;
|
|
48
|
+
const services = Object.entries(servicesObj).map(([name, svc]) => ({
|
|
49
|
+
name,
|
|
50
|
+
ports: parseServicePorts(svc.ports),
|
|
51
|
+
build: parseServiceBuild(svc.build),
|
|
52
|
+
envFile: parseEnvFile(svc.env_file),
|
|
53
|
+
}));
|
|
54
|
+
return { services, composePath };
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function loadConfig(repoRoot) {
|
|
4
|
+
const rcPath = path.join(repoRoot, ".wtcrc.json");
|
|
5
|
+
if (fs.existsSync(rcPath)) {
|
|
6
|
+
return JSON.parse(fs.readFileSync(rcPath, "utf-8"));
|
|
7
|
+
}
|
|
8
|
+
const pkgPath = path.join(repoRoot, "package.json");
|
|
9
|
+
if (fs.existsSync(pkgPath)) {
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
11
|
+
if (pkg.wtc && typeof pkg.wtc === "object") {
|
|
12
|
+
return pkg.wtc;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComposeFile } from "./compose/types.js";
|
|
2
|
+
import type { PortMapping } from "./ports/types.js";
|
|
3
|
+
import type { WtcConfig } from "./config.js";
|
|
4
|
+
import type { WorktreeInfo } from "./git/worktree.js";
|
|
5
|
+
export interface WtcContext {
|
|
6
|
+
repoRoot: string;
|
|
7
|
+
repoName: string;
|
|
8
|
+
composeFile: ComposeFile;
|
|
9
|
+
portMappings: PortMapping[];
|
|
10
|
+
config: WtcConfig;
|
|
11
|
+
worktrees: WorktreeInfo[];
|
|
12
|
+
}
|
|
13
|
+
export declare function buildContext(): WtcContext;
|
|
14
|
+
export declare function filterWorktrees(worktrees: WorktreeInfo[], indices: number[]): WorktreeInfo[];
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getRepoRoot, getRepoName, detectComposeFile } from "./compose/detect.js";
|
|
2
|
+
import { parseComposeFile } from "./compose/parse.js";
|
|
3
|
+
import { extractPortMappings } from "./ports/extract.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { getNonMainWorktrees } from "./git/worktree.js";
|
|
6
|
+
export function buildContext() {
|
|
7
|
+
const repoRoot = getRepoRoot();
|
|
8
|
+
const repoName = getRepoName(repoRoot);
|
|
9
|
+
const composePath = detectComposeFile(repoRoot);
|
|
10
|
+
if (!composePath) {
|
|
11
|
+
throw new Error(`No compose file found in ${repoRoot}. Expected one of: compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml`);
|
|
12
|
+
}
|
|
13
|
+
const composeFile = parseComposeFile(composePath);
|
|
14
|
+
const portMappings = extractPortMappings(composeFile.services);
|
|
15
|
+
const config = loadConfig(repoRoot);
|
|
16
|
+
const worktrees = getNonMainWorktrees(repoRoot);
|
|
17
|
+
return { repoRoot, repoName, composeFile, portMappings, config, worktrees };
|
|
18
|
+
}
|
|
19
|
+
export function filterWorktrees(worktrees, indices) {
|
|
20
|
+
if (indices.length === 0)
|
|
21
|
+
return worktrees;
|
|
22
|
+
return indices
|
|
23
|
+
.map((i) => {
|
|
24
|
+
const wt = worktrees[i - 1];
|
|
25
|
+
if (!wt)
|
|
26
|
+
throw new Error(`Worktree index ${i} not found. Run 'wtc list' to see available worktrees.`);
|
|
27
|
+
return wt;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function getChangedFiles(repoRoot: string, wtPath: string, currentBranch: string, wtBranch: string): string[];
|
|
2
|
+
export declare function getLocalDirtyFiles(repoRoot: string): string[];
|
|
3
|
+
export declare function findConflicts(files: string[], dirtyFiles: string[]): string[];
|
|
4
|
+
export declare function promoteFiles(repoRoot: string, wtPath: string, files: string[]): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { exec, execSafe } from "../utils/exec.js";
|
|
4
|
+
const PROMOTE_EXCLUDES = new Set([
|
|
5
|
+
".env",
|
|
6
|
+
"docker-compose.yml",
|
|
7
|
+
"docker-compose.yaml",
|
|
8
|
+
"compose.yml",
|
|
9
|
+
"compose.yaml",
|
|
10
|
+
]);
|
|
11
|
+
function resolveRef(repoRoot, ref) {
|
|
12
|
+
return exec(`git -C "${repoRoot}" rev-parse "${ref}"`);
|
|
13
|
+
}
|
|
14
|
+
export function getChangedFiles(repoRoot, wtPath, currentBranch, wtBranch) {
|
|
15
|
+
const currentRef = resolveRef(repoRoot, currentBranch === "HEAD" ? "HEAD" : currentBranch);
|
|
16
|
+
const wtRef = resolveRef(wtPath, "HEAD");
|
|
17
|
+
const mergeBase = exec(`git -C "${repoRoot}" merge-base "${currentRef}" "${wtRef}"`);
|
|
18
|
+
const committed = execSafe(`git -C "${wtPath}" diff --name-only "${mergeBase}" HEAD`) ?? "";
|
|
19
|
+
let uncommitted = "";
|
|
20
|
+
const hasStagedOrUnstaged = execSafe(`git -C "${wtPath}" diff --quiet`) === null ||
|
|
21
|
+
execSafe(`git -C "${wtPath}" diff --cached --quiet`) === null;
|
|
22
|
+
if (hasStagedOrUnstaged) {
|
|
23
|
+
uncommitted = execSafe(`git -C "${wtPath}" diff --name-only HEAD`) ?? "";
|
|
24
|
+
}
|
|
25
|
+
const untracked = execSafe(`git -C "${wtPath}" ls-files --others --exclude-standard`) ?? "";
|
|
26
|
+
const allFiles = [committed, uncommitted, untracked]
|
|
27
|
+
.join("\n")
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((f) => f.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
const unique = [...new Set(allFiles)];
|
|
32
|
+
return unique.filter((f) => !PROMOTE_EXCLUDES.has(f));
|
|
33
|
+
}
|
|
34
|
+
export function getLocalDirtyFiles(repoRoot) {
|
|
35
|
+
const unstaged = execSafe(`git -C "${repoRoot}" diff --name-only HEAD`) ?? "";
|
|
36
|
+
const staged = execSafe(`git -C "${repoRoot}" diff --cached --name-only HEAD`) ?? "";
|
|
37
|
+
return [...new Set([unstaged, staged].join("\n").split("\n"))]
|
|
38
|
+
.map((f) => f.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
export function findConflicts(files, dirtyFiles) {
|
|
42
|
+
const dirtySet = new Set(dirtyFiles);
|
|
43
|
+
return files.filter((f) => dirtySet.has(f));
|
|
44
|
+
}
|
|
45
|
+
export function promoteFiles(repoRoot, wtPath, files) {
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const src = path.join(wtPath, f);
|
|
48
|
+
const dst = path.join(repoRoot, f);
|
|
49
|
+
if (fs.existsSync(src)) {
|
|
50
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
51
|
+
fs.copyFileSync(src, dst);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
if (fs.existsSync(dst))
|
|
55
|
+
fs.unlinkSync(dst);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface WorktreeInfo {
|
|
2
|
+
path: string;
|
|
3
|
+
branch: string;
|
|
4
|
+
isMain: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function getWorktrees(repoRoot: string): WorktreeInfo[];
|
|
7
|
+
export declare function getNonMainWorktrees(repoRoot: string): WorktreeInfo[];
|
|
8
|
+
export declare function getWorktreeByIndex(repoRoot: string, index: number): WorktreeInfo | null;
|
|
9
|
+
export declare function getWorktreeBranch(wtPath: string): string;
|
|
10
|
+
export declare function getWorktreeHead(wtPath: string): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exec, execSafe } from "../utils/exec.js";
|
|
2
|
+
export function getWorktrees(repoRoot) {
|
|
3
|
+
const output = exec(`git -C "${repoRoot}" worktree list --porcelain`);
|
|
4
|
+
const blocks = output.split("\n\n").filter(Boolean);
|
|
5
|
+
const worktrees = [];
|
|
6
|
+
for (const block of blocks) {
|
|
7
|
+
const lines = block.split("\n");
|
|
8
|
+
let wtPath = "";
|
|
9
|
+
let branch = "detached";
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
if (line.startsWith("worktree ")) {
|
|
12
|
+
wtPath = line.slice("worktree ".length);
|
|
13
|
+
}
|
|
14
|
+
if (line.startsWith("branch ")) {
|
|
15
|
+
branch = line.slice("branch ".length).replace("refs/heads/", "");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (wtPath) {
|
|
19
|
+
worktrees.push({
|
|
20
|
+
path: wtPath,
|
|
21
|
+
branch,
|
|
22
|
+
isMain: worktrees.length === 0,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return worktrees;
|
|
27
|
+
}
|
|
28
|
+
export function getNonMainWorktrees(repoRoot) {
|
|
29
|
+
return getWorktrees(repoRoot).filter((wt) => !wt.isMain);
|
|
30
|
+
}
|
|
31
|
+
export function getWorktreeByIndex(repoRoot, index) {
|
|
32
|
+
const nonMain = getNonMainWorktrees(repoRoot);
|
|
33
|
+
return nonMain[index - 1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
export function getWorktreeBranch(wtPath) {
|
|
36
|
+
return (execSafe(`git -C "${wtPath}" rev-parse --abbrev-ref HEAD`) ?? "detached");
|
|
37
|
+
}
|
|
38
|
+
export function getWorktreeHead(wtPath) {
|
|
39
|
+
return exec(`git -C "${wtPath}" rev-parse HEAD`);
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startMcpServer(): Promise<void>;
|