Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 125 additions & 15 deletions src/services/Git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ import { BranchRef, branchRef, ExecError } from "../domain/model.ts";
import * as Proc from "../platform/proc.ts";
import { StackConfig } from "./Config.ts";

export interface Worktree {
readonly path: string;
readonly head: string | null;
readonly branch: string | null;
readonly dirty: ReadonlyArray<string>;
}

export interface Interface {
readonly dirty: () => Effect.Effect<ReadonlyArray<string>, ExecError>;
readonly worktrees: () => Effect.Effect<ReadonlyArray<Worktree>, ExecError>;
readonly fetch: () => Effect.Effect<void, ExecError>;
readonly remotes: () => Effect.Effect<
ReadonlyArray<{ readonly name: string; readonly url: string }>,
Expand Down Expand Up @@ -51,14 +59,89 @@ export const live = Layer.effect(
const cfg = yield* StackConfig;
const proc = yield* Proc.Service;

const run = Effect.fn("Git.run")(function* (
const runAt = Effect.fn("Git.runAt")(function* (
cwd: string,
tool: string,
args: ReadonlyArray<string>,
ok: ReadonlyArray<number> = [0],
) {
return yield* proc.exec(cfg.root, tool, args, ok);
return yield* proc.exec(cwd, tool, args, ok);
});
const run = Effect.fn("Git.run")(
(tool: string, args: ReadonlyArray<string>, ok: ReadonlyArray<number> = [0]) =>
runAt(cfg.root, tool, args, ok),
);

const dirtyAt = Effect.fn("Git.dirtyAt")((path: string) =>
runAt(path, "git", ["status", "--short"]).pipe(
Effect.map((out) => out.split("\n").filter(Boolean)),
),
);

const worktrees = Effect.fn("Git.worktrees")(function* () {
const out = yield* run("git", ["worktree", "list", "--porcelain", "-z"]);
const records: Array<{
path: string;
head: string | null;
branch: string | null;
prunable: boolean;
}> = [];
let current: {
path: string;
head: string | null;
branch: string | null;
prunable: boolean;
} | null = null;
for (const field of out.split("\0").filter(Boolean)) {
if (field.startsWith("worktree ")) {
if (current) records.push(current);
current = {
path: field.slice("worktree ".length),
head: null,
branch: null,
prunable: false,
};
continue;
}
if (!current) continue;
if (field.startsWith("HEAD ")) current.head = field.slice("HEAD ".length);
else if (field.startsWith("branch refs/heads/"))
current.branch = field.slice("branch refs/heads/".length);
else if (field === "detached") current.branch = null;
else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true;
}
if (current) records.push(current);

return yield* Effect.forEach(
records.filter((record) => !record.prunable),
(record) =>
dirtyAt(record.path).pipe(
Effect.map(
(dirty): Worktree => ({
path: record.path,
head: record.head,
branch: record.branch,
dirty,
}),
),
),
{ concurrency: "unbounded" },
);
});

const checkedOutDirtyError = (branch: string, worktree: Worktree) =>
new ExecError(
"git",
["replay", branch],
1,
[
`${branch} is checked out at ${worktree.path} with local changes:`,
...worktree.dirty.map((line) => ` ${line}`),
"",
`Commit, stash, or clean that worktree before repairing ${branch}.`,
].join("\n"),
);

const refs = Effect.fn("Git.refs")(function* () {
const out = yield* run("git", [
"for-each-ref",
Expand All @@ -75,9 +158,7 @@ export const live = Layer.effect(
.map(([name, head]) => branchRef({ name, head }));
});

const dirty = Effect.fn("Git.dirty")(() =>
run("git", ["status", "--short"]).pipe(Effect.map((out) => out.split("\n").filter(Boolean))),
);
const dirty = Effect.fn("Git.dirty")(() => dirtyAt(cfg.root));

const current = Effect.fn("Git.current")(() => run("git", ["branch", "--show-current"]));
const remote = Effect.fn("Git.remote")(() =>
Expand Down Expand Up @@ -145,27 +226,40 @@ export const live = Layer.effect(
parent: string,
commits: ReadonlyArray<string>,
) {
const current = yield* run("git", ["branch", "--show-current"]);
const owner = (yield* worktrees()).find((worktree) => worktree.branch === branch) ?? null;
if (owner && owner.dirty.length > 0) {
return yield* Effect.fail(checkedOutDirtyError(branch, owner));
}

const root = owner?.path ?? cfg.root;
const current = yield* runAt(root, "git", ["branch", "--show-current"]);
const now = yield* Clock.currentTimeMillis;
const temp = `stack/replay-${now}-${branch.replaceAll("/", "-")}`;
const abortCherryPick = run("git", ["cherry-pick", "--abort"], [0, 1, 128]).pipe(
const abortCherryPick = runAt(root, "git", ["cherry-pick", "--abort"], [0, 1, 128]).pipe(
Effect.asVoid,
Effect.orDie,
);
const deleteTemp = run("git", ["branch", "-D", temp], [0, 1]).pipe(
const deleteTemp = runAt(root, "git", ["branch", "-D", temp], [0, 1]).pipe(
Effect.asVoid,
Effect.orDie,
);
const restoreCurrent = current
? run("git", ["checkout", current]).pipe(Effect.asVoid, Effect.orDie)
? runAt(root, "git", ["checkout", current]).pipe(Effect.asVoid, Effect.orDie)
: Effect.void;

yield* Effect.gen(function* () {
yield* run("git", ["checkout", "-B", temp, parent]).pipe(Effect.asVoid);
yield* runAt(root, "git", ["checkout", "-B", temp, parent]).pipe(Effect.asVoid);
if (commits.length > 0) {
yield* run("git", ["cherry-pick", "--empty=drop", ...commits]).pipe(Effect.asVoid);
yield* runAt(root, "git", ["cherry-pick", "--empty=drop", ...commits]).pipe(
Effect.asVoid,
);
}
if (owner) {
yield* runAt(root, "git", ["checkout", branch]).pipe(Effect.asVoid);
yield* runAt(root, "git", ["reset", "--hard", temp]).pipe(Effect.asVoid);
} else {
yield* runAt(root, "git", ["branch", "-f", branch, temp]).pipe(Effect.asVoid);
}
yield* run("git", ["branch", "-f", branch, temp]).pipe(Effect.asVoid);
}).pipe(
Effect.ensuring(
abortCherryPick.pipe(Effect.ensuring(restoreCurrent.pipe(Effect.ensuring(deleteTemp)))),
Expand All @@ -175,9 +269,23 @@ export const live = Layer.effect(
const backup = Effect.fn("Git.backup")((branch: string, name: string) =>
run("git", ["branch", "-f", name, branch]).pipe(Effect.asVoid),
);
const drop = Effect.fn("Git.drop")((branch: string) =>
run("git", ["branch", "-D", branch], [0, 1]).pipe(Effect.asVoid),
);
const drop = Effect.fn("Git.drop")(function* (branch: string) {
const owner =
(yield* worktrees()).find(
(worktree) => worktree.branch === branch && worktree.path !== cfg.root,
) ?? null;
if (owner) {
return yield* Effect.fail(
new ExecError(
"git",
["branch", "-D", branch],
1,
`${branch} is checked out at ${owner.path}; detach or remove that worktree before deleting the local branch.`,
),
);
}
return yield* run("git", ["branch", "-D", branch], [0, 1]).pipe(Effect.asVoid);
});
const restore = Effect.fn("Git.restore")((branch: string, name: string) =>
run("git", ["branch", "-f", branch, name]).pipe(Effect.asVoid),
);
Expand All @@ -193,6 +301,7 @@ export const live = Layer.effect(
fetch,
remotes,
dirty,
worktrees,
refs,
current,
remote,
Expand Down Expand Up @@ -221,6 +330,7 @@ export const test = (opts: {
Service.of({
fetch: () => Effect.void,
dirty: () => Effect.succeed([]),
worktrees: () => Effect.succeed([]),
refs: () => Effect.succeed(opts.refs ?? []),
remotes: () => Effect.succeed([]),
current: () => Effect.succeed(opts.current ?? ""),
Expand Down
93 changes: 82 additions & 11 deletions src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ ${note}`;
}),
);

const ensureRepairableWorktrees = Effect.fn("Stack.ensureRepairableWorktrees")(function* (
branches: ReadonlyArray<string>,
) {
const wanted = new Set(branches);
if (wanted.size === 0) return;
const dirty = (yield* git.worktrees()).filter(
(item) => item.branch && wanted.has(item.branch) && item.dirty.length > 0,
);
if (dirty.length === 0) return;
return yield* Effect.fail(
new StackOperationError(
[
"Cannot repair checked-out dirty worktree branches:",
"",
...dirty.flatMap((item) => [
` ${item.branch} -> ${item.path}`,
...item.dirty.map((line) => ` ${line}`),
]),
"",
"Commit, stash, or clean those worktrees, then rerun the command.",
].join("\n"),
),
);
});

const trunk = (name: string) => cfg.trunks.some((item) => item === name);
const step = (message: string) => progress.emit({ _tag: "Step", message });
const wait = (message: string) => progress.emit({ _tag: "Wait", message });
Expand Down Expand Up @@ -798,6 +823,45 @@ ${note}`;
}
};

const plannedRepairBranches = Effect.fn("Stack.repairStack.plannedRepairBranches")(
function* () {
const branches = new Set<string>();
const plannedMoved = new Set<string>();
const plannedTips = new Map<string, string | null>();

for (const link of [...state.links].sort(
(a, b) => graph.rank(String(a.branch)) - graph.rank(String(b.branch)),
)) {
if (!live.has(String(link.branch))) continue;

const parent = resolve(String(link.parent));
if (!parent) continue;

const onto = trunk(parent) ? `origin/${parent}` : parent;
if (!plannedTips.has(onto)) {
const tip = yield* git.head(onto);
plannedTips.set(onto, Option.isSome(tip) ? tip.value : null);
}
const want = plannedTips.get(onto) ?? heads.get(parent) ?? null;
const have = yield* git.base(link.branch, onto);
const drift =
replayAnchors.has(String(link.branch)) ||
parent !== link.parent ||
plannedMoved.has(parent) ||
(want && (Option.isNone(have) || have.value !== want));

if (drift) {
branches.add(String(link.branch));
plannedMoved.add(String(link.branch));
}
}

return branches;
},
);

if (apply) yield* ensureRepairableWorktrees([...(yield* plannedRepairBranches())]);

for (const link of [...state.links].sort(
(a, b) => graph.rank(String(a.branch)) - graph.rank(String(b.branch)),
)) {
Expand Down Expand Up @@ -1665,6 +1729,23 @@ ${note}`;
})
: item;
});
const repairPulls = yield* changesForLinks(
scopedState.links.filter((item) => item.branch !== target),
plannedPulls,
);
const plannedRepair = yield* repairStack(
scopedState,
refs.filter((item) => item.name !== target),
repairPulls,
{ apply: false },
);
if (active) {
yield* ensureRepairableWorktrees(
plannedRepair.actions.flatMap((item) =>
item._tag === "Rebase" ? [String(item.branch)] : [],
),
);
}
const actions = [
...(current === target ? [`${active ? "" : "would "}switch to ${root}`] : []),
...(hasLocalTarget ? [`${active ? "" : "would "}backup ${target} -> ${name}`] : []),
Expand Down Expand Up @@ -1744,18 +1825,8 @@ ${note}`;
return yield* repairAfterMerge();
}

const repairPulls = yield* changesForLinks(
scopedState.links.filter((item) => item.branch !== target),
plannedPulls,
);
const repair = yield* repairStack(
scopedState,
refs.filter((item) => item.name !== target),
repairPulls,
{ apply: false },
);
const tail = next ? `next root: ${next}` : "next root: none";
return [...actions, ...repair.lines, tail];
return [...actions, ...plannedRepair.lines, tail];
}),
);

Expand Down
Loading