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

  • Run git commands git.writeHighRuns any git command.Stage, commit, switch branches and sync with the remote
  • Read chats chats.readMediumReads your transcripts and chat lists.Refresh when an agent finishes a turn
  • Read git status and diffs git.readLowReads git status and diffs.Show the repository's status and diffs, and follow its changes
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the source control button and panelSlots: railright

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <313.4 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.