Official
git
Built-in git UI (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/git@0.2.0
Permissions in 0.2.0
Files
actions.ts7.9 KB
// Repository commands and tab requests. Effects capture the host once in the layer.
// Diffs and files open through the convergence/tabs service when it runs.
// Command order, smart commit and the unborn-HEAD unstage fallback match the JS plugin.
import * as Effect from "effect/Effect";
import { absurd } from "effect/Function";
import { App, Host, Notify, Views } from "convergence/effect";
import type { GitStatus } from "convergence";
import { discardPaths, groups, pathsOf } from "./status.ts";
import type { Entry, GitState, Groups, MenuAction } from "./status.ts";
export const makeActions = Effect.fn("Git.makeActions")(function* (
state: GitState,
refresh: () => Effect.Effect<void>,
redraw: () => void,
) {
const host = yield* Host;
const app = yield* App;
const notify = yield* Notify;
const views = yield* Views;
// Repository actions are only rendered after show has set the workspace.
const workspaceId = () => state.workspaceId!;
// Commit, publish and sync are only exposed once a repository status is loaded.
const status = () => state.status!;
const git = Effect.fn("Git.run")(function* (args: readonly string[], label?: string) {
state.busy = label ?? `git ${args[0]}`;
state.confirm = null;
redraw();
const success = yield* host.call("host/git.run", { workspaceId: workspaceId(), args }).pipe(
Effect.flatMap((result) =>
Effect.gen(function* () {
if (result.code !== 0)
yield* notify.error((result.stderr || result.stdout).trim() || `git ${args[0]} failed`);
return result.code === 0;
}),
),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
notify.error(error.message).pipe(Effect.as(false)),
),
Effect.ensuring(
Effect.sync(() => {
state.busy = null;
}),
),
);
yield* refresh();
return success;
});
const openDiff = Effect.fn("Git.openDiff")(function* (entry: Entry) {
const staged = entry.side === "staged";
yield* host
.call("host/git.diff", {
workspaceId: workspaceId(),
path: entry.path,
origPath: staged ? entry.origPath : undefined,
staged,
})
.pipe(
Effect.flatMap((result) => {
const client = state.tabs;
const id = state.workspaceId;
// Without the tabs plugin there is no detail pane; the shared
// request the baseline wrote would have had no reader either.
if (!client || !id) return Effect.void;
return client
.open({ kind: "diff", workspaceId: id, path: entry.path, diff: result.diff })
.pipe(
// A call that loses its provider on the way behaves like no reader.
Effect.ignore,
);
}),
Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
notify.error(error.message),
),
);
});
const openFile = Effect.fn("Git.openFile")((entry: Entry) => {
const client = state.tabs;
const id = state.workspaceId;
if (!client || !id) return Effect.void;
return client.open({ kind: "file", workspaceId: id, path: entry.path }).pipe(
// A call that loses its provider on the way behaves like no reader.
Effect.ignore,
);
});
const stage = Effect.fn("Git.stage")((entries: readonly Entry[]) =>
git(["add", "-A", "--", ...pathsOf(entries)], "Staging"),
);
const unstage = Effect.fn("Git.unstage")(function* (entries: readonly Entry[]) {
const paths = pathsOf(entries);
const result = yield* host.call("host/git.run", {
workspaceId: workspaceId(),
args: ["reset", "-q", "--", ...paths],
});
if (result.code === 0) return yield* refresh();
yield* git(["rm", "--cached", "-r", "-q", "--", ...paths], "Unstaging");
});
const discard = Effect.fn("Git.discard")(function* (entries: readonly Entry[]) {
const { tracked, untracked } = discardPaths(entries);
if (tracked.length && !(yield* git(["checkout", "--", ...tracked], "Discarding"))) return;
if (untracked.length) yield* git(["clean", "-fq", "--", ...untracked], "Discarding");
});
const commit = Effect.fn("Git.commit")(function* ({ amend = false }: { amend?: boolean } = {}) {
const message = state.message.trim();
if (!message && !amend) return;
const { staged, changes } = groups(status());
if (!amend && !staged.length && changes.length && !(yield* git(["add", "-A"], "Staging"))) return;
const args = amend
? message
? ["commit", "-q", "--amend", "-m", message]
: ["commit", "-q", "--amend", "--no-edit"]
: ["commit", "-q", "-m", message];
const text = state.message;
state.message = "";
if (!(yield* git(args, "Committing"))) state.message = text;
redraw();
});
const sync = Effect.fn("Git.sync")(function* () {
const current = status();
if (current.behind && !(yield* git(["pull", "--no-rebase", "--no-edit"], "Pulling"))) return;
if (current.ahead || current.behind) yield* git(["push"], "Pushing");
});
const publish = Effect.fn("Git.publish")(function* () {
const result = yield* host.call("host/git.run", { workspaceId: workspaceId(), args: ["remote"] });
const remotes = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const remote = remotes.includes("origin") ? "origin" : remotes[0];
if (!remote) return yield* notify.warning("This repository has no remote to publish to.");
// The host status uses git --branch, which always names a repository branch (including detached HEAD).
yield* git(["push", "-u", remote, status().branch!], "Publishing");
});
const openBranches = Effect.fn("Git.openBranches")(function* () {
state.branchOpen = !state.branchOpen;
state.branchQuery = "";
redraw();
if (!state.branchOpen) return;
yield* views.focus("git-branch-query");
const result = yield* host.call("host/git.run", {
workspaceId: workspaceId(),
args: ["branch", "--format=%(refname:short)"],
});
state.branches =
result.code === 0
? result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
: [];
redraw();
});
const checkout = Effect.fn("Git.checkout")(function* (name: string, create: boolean) {
state.branchOpen = false;
yield* git(create ? ["checkout", "-b", name] : ["checkout", name], create ? "Creating branch" : "Switching branch");
});
const menuAction = Effect.fn("Git.menuAction")(function* (action: MenuAction, current: GitStatus, grouped: Groups) {
switch (action) {
case "pull":
yield* git(["pull", "--no-rebase", "--no-edit"], "Pulling");
return;
case "push":
if (current.upstream) yield* git(["push"], "Pushing");
else yield* publish();
return;
case "fetch":
yield* git(["fetch", "--all", "--prune"], "Fetching");
return;
case "amend":
yield* commit({ amend: true });
return;
case "undo":
yield* git(["reset", "--soft", "HEAD~1"], "Undoing commit");
return;
case "stage-all":
yield* git(["add", "-A"], "Staging");
return;
case "unstage-all":
yield* unstage(grouped.staged);
return;
case "stash":
yield* git(["stash", "push", "--include-untracked"], "Stashing");
return;
case "pop":
yield* git(["stash", "pop"], "Popping stash");
return;
default:
return absurd(action);
}
});
const toggle = Effect.gen(function* () {
const current = yield* app.state;
yield* app.update({ shared: { rightPanel: current.shared?.rightPanel === "git" ? null : "git" } });
});
return {
git,
openDiff,
openFile,
stage,
unstage,
discard,
commit,
sync,
publish,
openBranches,
checkout,
menuAction,
toggle,
close: app.update({ shared: { rightPanel: null } }),
};
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 13.4 KB | 4 permissions | Listed |
No comments yet.