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
17 changes: 12 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Kubernetes plugin + built-in terminal) with a purpose-built tool that does exact
five things well:

1. One tab per project, where a project is a cloned git repository of manifests.
2. A file tree with git change awareness, plus pull / commit / push and diffs.
2. A file tree with git change awareness, plus pull / push and diffs.
3. Light, schema-aware YAML editing and markdown viewing/editing.
4. Diff-before-apply Kubernetes workflows for plain YAML and Helm charts.
5. A first-class embedded terminal for running Claude Code per project.
Expand Down Expand Up @@ -102,7 +102,8 @@ One process, three layers:
- **Project registry** — the list of managed repos and their per-project settings
(§4). Owns lifecycle: add (clone or point at existing checkout), open, remove.
- **Git service** — wraps `git` invocations per project: `status --porcelain=v2`,
`diff`, `log`, `pull`, `push`, `commit`, branch info. Emits change events.
`diff`, `log`, `pull`, `push`, branch info. Emits change events. Nothing here
writes the index — see §7.
- **FS watcher** — fsnotify on each open project's worktree and `.git/HEAD` /
`.git/refs`; debounced events drive tree badges and editor reload prompts.
Polling fallback where fsnotify is unreliable (network mounts).
Expand All @@ -125,7 +126,7 @@ One process, three layers:

Two channels, chosen by payload profile:

- **Wails bindings** for RPC (open file, git commit, run apply, get project
- **Wails bindings** for RPC (open file, git pull, run apply, get project
list): request/response, typed, low volume.
- **Loopback WebSocket server** (127.0.0.1, random port, per-launch bearer token
required on connect) for streams: PTY input/output (binary frames), live
Expand Down Expand Up @@ -250,12 +251,18 @@ both ways from the UI without an explicit switch.
v1 scope mirrors actual daily use, not a git client:

- Status-driven tree badges and a changes list per project.
- Stage/unstage, commit (with message editor), pull (rebase per repo config),
push, current branch + ahead/behind in the status bar.
- Pull (rebase per repo config), push, current branch + ahead/behind in the status bar.
- Diff viewer for working-tree changes and for a file's last commit.
- Branch switching (existing branches). Branch creation, log browsing, stash,
and history tooling are v1.x (§10).

**Staging and committing are not m6t's.** The agent in the terminal (§5) does that
work, running the user's own `git` in the user's own worktree — so m6t offers no commit
box and no stage/unstage control, and its bound surface has no method that writes the
index. Two writers of one index, only one of which the agent can see, is two tools
disagreeing about one repository. The changes list still groups staged and unstaged
separately, because that is what `git status` reports whoever put the paths there.

All operations run the system `git`; failures surface stderr verbatim (the user
knows how to read git errors — do not translate them).

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ Managing fleets of manifest repos doesn't need an IDE — it needs a small set o
things done extremely well:

- **Projects** — each cloned manifest repo is a top-level tab.
- **Git** — status badges in the file tree, pull / commit / push, proper diffs.
- **Git** — status badges in the file tree, pull / push, proper diffs. Committing
is the terminal agent's job, not m6t's (DESIGN.md §7).
- **Editing** — CodeMirror-based YAML with Kubernetes schema diagnostics, and
markdown preview. Light by design.
- **Apply** — every cluster mutation goes validate → diff → confirm → apply,
Expand Down
88 changes: 60 additions & 28 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ import { MODIFIED, emptyStatus } from "./lib/git";
function stubGit(overrides: Partial<Git> = {}): Git {
return {
status: () => Promise.resolve(emptyStatus()),
stage: () => Promise.resolve(),
unstage: () => Promise.resolve(),
commit: () => Promise.resolve(),
pull: () => Promise.resolve(),
push: () => Promise.resolve(),
checkout: () => Promise.resolve(),
Expand Down Expand Up @@ -614,37 +611,39 @@ describe("the git operations (#9)", () => {
/** A repository whose status changes when the seam is written to, so the
* refresh-after-an-operation contract is observable rather than asserted on
* a call count. */
function stagingGit() {
let staged = false;
function pullingGit() {
let pulled = false;
const empty = emptyStatus();
const branch = { ...empty.branch, name: "main", upstream: "origin/main" };
const status = () =>
Promise.resolve({
...empty,
branch,
files: [
{
path: "a.yaml",
staged: staged ? MODIFIED : "",
worktree: staged ? "" : MODIFIED,
conflicted: false,
origPath: "",
},
],
files: pulled
? []
: [
{
path: "a.yaml",
staged: "",
worktree: MODIFIED,
conflicted: false,
origPath: "",
},
],
});
const stage = vi.fn(() => {
staged = true;
const pull = vi.fn(() => {
pulled = true;
return Promise.resolve();
});
return { seam: stubGit({ status, stage, branches: () => Promise.resolve(["main"]) }), stage };
return { seam: stubGit({ status, pull, branches: () => Promise.resolve(["main"]) }), pull };
}

// The composition test: the panel's button, the ops hook, the seam, and the
// status re-read that makes the row move. Each of those has its own unit
// test; this is the only thing that fails when they are wired to each other
// wrongly.
it("stages a file from the changes panel and shows the result", async () => {
const { seam, stage } = stagingGit();
// The composition test: the branch bar's button, the ops hook, the seam, and
// the status re-read that makes the row go away. Each of those has its own
// unit test; this is the only thing that fails when they are wired to each
// other wrongly.
it("pulls from the branch bar and shows the result", async () => {
const { seam, pull } = pullingGit();
render(
<App
load={attached}
Expand All @@ -653,18 +652,51 @@ describe("the git operations (#9)", () => {
/>,
);

const button = await screen.findByRole("button", { name: "Stage a.yaml" });
// The button renders disabled first — the initial status has no upstream —
// and only becomes usable once the real one lands.
const button = await screen.findByRole("button", { name: "Pull" });
await waitFor(() => {
expect((button as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.getByRole("button", { name: "Unstaged: a.yaml" })).toBeDefined();

fireEvent.click(button);

await waitFor(() => {
expect(stage).toHaveBeenCalledWith("/w/infra", ["a.yaml"]);
expect(pull).toHaveBeenCalledWith("/w/infra");
});
// The row moved groups, which only happens if the operation triggered a
// The row went away, which only happens if the operation triggered a
// re-read of the status.
await waitFor(() => {
expect(screen.getByRole("button", { name: "Staged: a.yaml" })).toBeDefined();
expect(screen.queryByRole("button", { name: "Unstaged: a.yaml" })).toBeNull();
});
expect(screen.queryByRole("button", { name: "Unstaged: a.yaml" })).toBeNull();
});

// The commit box and the stage/unstage controls are gone (#39): what records
// work is the agent in the terminal, and this is the assertion that fails if
// one of them comes back through a component the workbench still renders.
it("offers no control anywhere that writes the index", async () => {
const { seam } = pullingGit();
render(
<App
load={attached}
endpoint={pending}
backend={{ registry: fakeRegistry([project("infra", "/w/infra")]), git: seam }}
/>,
);

// A changed file on screen, so the panel is rendering rows rather than
// being absent for some unrelated reason.
await screen.findByRole("button", { name: "Unstaged: a.yaml" });

// Exact names, not a prefix: a row's own accessible name starts with
// "Unstaged:", so a prefix match would find the row and pass for the wrong
// reason.
for (const name of ["Stage a.yaml", "Unstage a.yaml", "Stage all", "Unstage all", "Commit"]) {
expect(screen.queryByRole("button", { name })).toBeNull();
}
expect(screen.queryByLabelText("Commit subject")).toBeNull();
expect(screen.queryByLabelText("Commit body")).toBeNull();
});

// A failed operation reaches the user with git's own words in it — the whole
Expand Down
110 changes: 36 additions & 74 deletions frontend/src/components/ChangesPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ function renderPanel(props: Partial<ChangesPanelProps> = {}) {
status: emptyStatus(),
error: null,
onOpenFile: vi.fn(),
onStage: vi.fn(),
onUnstage: vi.fn(),
busy: false,
...props,
};
render(<ChangesPanel {...merged} />);
Expand Down Expand Up @@ -92,80 +89,45 @@ describe("the changes list", () => {
});
});

describe("staging from the panel", () => {
it("stages one file from its unstaged row", () => {
const { onStage } = renderPanel({
status: statusOf([file("a.yaml", { worktree: MODIFIED })]),
});

fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" }));

expect(onStage).toHaveBeenCalledWith(["a.yaml"]);
});

it("unstages one file from its staged row", () => {
const { onUnstage } = renderPanel({
status: statusOf([file("a.yaml", { staged: ADDED })]),
});

fireEvent.click(screen.getByRole("button", { name: "Unstage a.yaml" }));

expect(onUnstage).toHaveBeenCalledWith(["a.yaml"]);
});

// The group action sends every path in that group in one call, so the whole
// group moves in one git invocation rather than one per row.
it("stages a whole group at once", () => {
const { onStage } = renderPanel({
describe("what the panel does not do", () => {
// The panel reports; the agent in the terminal writes (#39). A button here
// would be a second writer of the index that the agent cannot see.
it("offers no staging control on any row", () => {
renderPanel({
status: statusOf([
file("a.yaml", { worktree: MODIFIED }),
file("b.yaml", { worktree: UNTRACKED }),
file("b.yaml", { staged: ADDED }),
file("new.yaml", { staged: RENAMED, origPath: "old.yaml" }),
]),
});

fireEvent.click(screen.getByRole("button", { name: "Stage all" }));

expect(onStage).toHaveBeenCalledWith(["a.yaml", "b.yaml"]);
});

// A file staged and then edited again is one row in each group, and each
// row's action moves only its own side.
it("offers both actions for a file that is in both groups", () => {
const { onStage, onUnstage } = renderPanel({
status: statusOf([file("a.yaml", { staged: ADDED, worktree: MODIFIED })]),
});

fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" }));
fireEvent.click(screen.getByRole("button", { name: "Unstage a.yaml" }));

expect(onStage).toHaveBeenCalledWith(["a.yaml"]);
expect(onUnstage).toHaveBeenCalledWith(["a.yaml"]);
});

// A rename is one row and two paths. Unstaging only the new name after a
// `git mv` leaves the old one staged as a deletion, which the next commit
// would carry out.
it("unstages both halves of a rename", () => {
const { onUnstage } = renderPanel({
status: statusOf([file("new.yaml", { staged: RENAMED, origPath: "old.yaml" })]),
});

fireEvent.click(screen.getByRole("button", { name: "Unstage new.yaml" }));

expect(onUnstage).toHaveBeenCalledWith(["new.yaml", "old.yaml"]);
});

// git serializes on the index, so a second click during an operation buys a
// lock error rather than a second operation.
it("disables every action while an operation is in flight", () => {
const { onStage } = renderPanel({
status: statusOf([file("a.yaml", { worktree: MODIFIED })]),
busy: true,
// Exact names, not a prefix: every row's own accessible name starts with
// "Staged:" or "Unstaged:", so a prefix match would find the rows and pass
// for the wrong reason.
for (const name of [
"Stage a.yaml",
"Unstage b.yaml",
"Unstage new.yaml",
"Stage all",
"Unstage all",
]) {
expect(screen.queryByRole("button", { name })).toBeNull();
}
});

// Every row is the button that opens its file, and nothing else is.
it("renders one button per row", () => {
renderPanel({
status: statusOf([
file("a.yaml", { worktree: MODIFIED }),
file("b.yaml", { staged: ADDED }),
]),
});

fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" }));

expect(onStage).not.toHaveBeenCalled();
expect(screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"))).toEqual([
"Staged: b.yaml",
"Unstaged: a.yaml",
]);
});
});

Expand All @@ -179,12 +141,12 @@ describe("conflicts", () => {
expect(screen.getByRole("status").textContent).toContain("Resolve these in the terminal");
});

// `git add` on a conflicted file means "I have resolved this". A user who
// has not should not be one misclick from claiming so.
it("gives a conflicted row no staging action", () => {
// A conflicted path groups with unstaged for badge purposes, but it belongs
// to its own section here: an unmerged file is not an ordinary edit waiting
// to be listed beside one.
it("keeps a conflicted path out of the unstaged group", () => {
renderPanel({ status: statusOf([file("a.yaml", { conflicted: true })]) });

expect(screen.queryByRole("button", { name: "Stage a.yaml" })).toBeNull();
expect(screen.queryByText("Unstaged")).toBeNull();
});

Expand Down
Loading
Loading