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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function useGit
created: 2026-10-16T22:18:47Z
norm: '1'
sig: 6c6d8173da981fc6
body_hash: d9f3d17ba0fba378
raw_hash: 730462f8973a08c0
vouched: 2026-10-16T22:49:12Z
body_hash: 35a4f47d1a607151
raw_hash: 36efc7db0f7d9765
vouched: 2026-10-22T14:35:52Z
lines: 28-57
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function useGit
created: 2026-10-16T22:30:58Z
norm: '1'
sig: 6c6d8173da981fc6
body_hash: d9f3d17ba0fba378
raw_hash: 730462f8973a08c0
vouched: 2026-10-16T22:49:12Z
body_hash: 35a4f47d1a607151
raw_hash: 36efc7db0f7d9765
vouched: 2026-10-22T14:35:52Z
lines: 67-153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function useGit
created: 2026-10-16T22:49:09Z
norm: '1'
sig: 6c6d8173da981fc6
body_hash: d9f3d17ba0fba378
raw_hash: 730462f8973a08c0
body_hash: 35a4f47d1a607151
raw_hash: 36efc7db0f7d9765
vouched: 2026-10-22T14:35:52Z
lines: 67-156
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function changes
created: 2026-10-16T22:30:58Z
norm: '1'
sig: f55c70c150e7bf86
body_hash: 449d72f9376e96c0
raw_hash: 75f3d6495136245b
body_hash: 3e0b55394db271ed
raw_hash: 0530a5cc5672aff8
vouched: 2026-10-22T14:35:52Z
lines: 123-161
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5J0KWKZ22SCJT7QY69G1P60
anchor: function fetchBase
created: 2026-10-22T14:35:35Z
norm: '1'
sig: 14f8df035e40ad8b
body_hash: 58a61d165604e742
raw_hash: 74cdb20c0b6f1e61
lines: 189-205
---

fetching stays a separate op the Diff calls once a minute; the changes read polled every 2 s must never fetch
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
body_hash: e05cfd4b5bb4c4b3
raw_hash: aeb6b16210ed310a
vouched: 2026-10-22T14:35:52Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
body_hash: e05cfd4b5bb4c4b3
raw_hash: aeb6b16210ed310a
vouched: 2026-10-22T14:35:52Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
body_hash: e05cfd4b5bb4c4b3
raw_hash: aeb6b16210ed310a
vouched: 2026-10-22T14:35:52Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
body_hash: e05cfd4b5bb4c4b3
raw_hash: aeb6b16210ed310a
vouched: 2026-10-22T14:35:52Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: ab25308bd79989e5
raw_hash: 8691059ceba82558
vouched: 2026-10-20T17:05:11Z
body_hash: e05cfd4b5bb4c4b3
raw_hash: aeb6b16210ed310a
vouched: 2026-10-22T14:35:52Z
lines: 127-873
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5J0KWMQ752DN1YW0ZYTPCXZ
anchor: function DiffView
created: 2026-10-22T14:35:35Z
norm: '1'
sig: e0a2f472fc8fbf16
body_hash: 40458c95dabb58e3
raw_hash: b081565ff0307166
lines: 368-373
---

view choices (tree, mode) live outside the keyed content, which remounts whenever the file list changes
1 change: 1 addition & 0 deletions apps/app/src/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Item } from "./transcript";
export type Ask =
| "create"
| "commit"
| "push"
| "update"
| "rebase"
| "review"
Expand Down
23 changes: 21 additions & 2 deletions apps/app/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ function fix(pull: GithubPull, failed: GithubCheck[]) {
const ASKS: Record<Exclude<Ask, "create">, (pull: number) => string> = {
commit: () =>
"Commit this lane's uncommitted work, following this repository's commit conventions. Leave out anything that doesn't belong, and don't push.",
push: () =>
"Push this branch's commits to its remote, with `lane push` in a lane and `git push` otherwise, following this repository's instructions. Don't open a pull request.",
update: (pull) =>
`Update #${pull} with this lane's work: commit what belongs in it, then push with \`lane push\`, following this repository's instructions.`,
rebase: () =>
Expand All @@ -46,6 +48,7 @@ const ASKS: Record<Exclude<Ask, "create">, (pull: number) => string> = {

const PENDING: Record<Exclude<Ask, "create">, Pick<GitAction, "label" | "icon">> = {
commit: { label: "Committing", icon: IconCommit },
push: { label: "Pushing", icon: IconPush },
update: { label: "Updating PR", icon: IconPush },
rebase: { label: "Rebasing", icon: IconBranch },
review: { label: "Addressing review", icon: IconReview },
Expand Down Expand Up @@ -107,6 +110,17 @@ export function useGit(channel: Listing, chat: number, user: string) {
if (!live && dirty(changes)) {
actions.push({ label: "Commit", icon: IconCommit, onClick: go("commit") });
}
// Not every project uses pull requests, or has its remote on GitHub.
if (!live && changes.ahead) {
const commits = `${changes.ahead} ${changes.ahead === 1 ? "commit" : "commits"}`;
actions.push({
label: "Push",
icon: IconPush,
count: `${changes.ahead}`,
title: `${commits} its remote doesn't have`,
onClick: go("push"),
});
}
if (live && (unpushed || dirty(changes))) {
actions.push({
label: "Update PR",
Expand Down Expand Up @@ -143,7 +157,10 @@ export function useGit(channel: Listing, chat: number, user: string) {
}
}

const open = ready && changed && pull === null && !asked;
// A closed pull request, or a merged one the branch has moved past, leaves its work unproposed.
const past = pull?.state === "closed"
|| (pull?.state === "merged" && !!changes && (pull.sha !== changes.head || dirty(changes)));
const open = ready && changed && (pull === null || past) && !asked;
return {
creatingPr: asked === "create",
createPr: open ? () => request("create", create(false)) : undefined,
Expand All @@ -168,7 +185,9 @@ export function useAsked(channel: string, busy: boolean, pull: GithubPull | null
ran.current = false;
return;
}
if (asked === "create" && pull) return publish(channel, { asked: null });
// A closed or merged pull request from before isn't the one Create PR asked for.
const live = pull?.state === "open" || pull?.state === "draft";
if (asked === "create" && live) return publish(channel, { asked: null });
if (busy) ran.current = true;
else if (ran.current) publish(channel, { asked: null });
}, [channel, asked, busy, pull]);
Expand Down
28 changes: 24 additions & 4 deletions apps/app/src/panels/diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DiffView, type DiffViewFile } from "@ace/ui";
import type { Change, Changes } from "@ace/channel/protocol";
import type { Listing } from "@ace/host/protocol";

import { useDetails } from "../details";
import { publish, useDetails } from "../details";
import { useGit } from "../git";
import { host } from "../host";

Expand All @@ -14,6 +14,8 @@ type Props = { channel: Listing; chat: number; user: string; active: boolean };
type Loaded = { stamp: string; patch?: string; signature?: string; error?: string };

const POLL = 2000;
// Fetching the lane's base is a network round trip, so it runs far less often than the poll.
const FETCH = 60_000;
const LIMIT = 4;
export const GROUPS = {
committed: "Committed",
Expand All @@ -40,6 +42,7 @@ function same(a: Changes | undefined, b: Changes) {
if (!a || a.cwd !== b.cwd || a.lane !== b.lane || a.base !== b.base || a.head !== b.head) {
return false;
}
if (a.against !== b.against || a.behind !== b.behind || a.ahead !== b.ahead) return false;
if (a.files.length !== b.files.length) return false;
if (JSON.stringify(a.dirs) !== JSON.stringify(b.dirs)) return false;
return a.files.every((change, i) =>
Expand All @@ -59,7 +62,9 @@ function signature(source: string) {
}

function short(ref: string) {
return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 7) : ref;
return /^[0-9a-f]{40}$/.test(ref)
? ref.slice(0, 7)
: ref.replace(/^refs\/(heads|remotes|tags)\//, "");
}

function subscribe(listener: () => void) {
Expand Down Expand Up @@ -116,6 +121,20 @@ export function Diff({ channel: listing, chat, user, active }: Props) {
};
}, [channel, chat, active, focused, status]);

// Git actions read the details' changes, so a fetch's new behind count reaches them from here.
useEffect(() => {
if (changes) publish(channel, { changes });
}, [channel, changes]);

// The changes read never fetches, so Rebase could otherwise wait on something else fetching.
useEffect(() => {
if (!active || !focused || status !== "open") return;
const fetch = () => void host.channel(channel, { op: "fetch", chat }).catch(() => {});
fetch();
const timer = setInterval(fetch, FETCH);
return () => clearInterval(timer);
}, [channel, chat, active, focused, status]);

useEffect(() => {
if (!changes || status !== "open") return;
const wanted = changes.files.filter((change) =>
Expand Down Expand Up @@ -175,6 +194,7 @@ export function Diff({ channel: listing, chat, user, active }: Props) {
adds: 0,
dels: 0,
note: `${count.toLocaleString()} files`,
count,
});
}
return files;
Expand All @@ -199,9 +219,9 @@ export function Diff({ channel: listing, chat, user, active }: Props) {
return (
<DiffView
files={files}
base={short(changes.base)}
base={short(changes.against || changes.base)}
head={changes.lane || short(changes.head)}
pr={pull
pr={pull && !git.createPr && !git.creatingPr
? { state: pull.state, number: String(pull.number), url: pull.url, title: pull.title }
: undefined}
creatingPr={git.creatingPr}
Expand Down
46 changes: 45 additions & 1 deletion packages/channel/src/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,25 @@ async function untracked(env: ExecutionEnv, cwd: string, context: Context) {
return { files, dirs };
}

/**
* Commits past the branch's upstream, or past the fork point of a lane that has a remote but was
* never pushed, since `lane push` sets the upstream.
*/
async function unpushed(
env: ExecutionEnv,
cwd: string,
fork: string | undefined,
context: Context,
) {
const upstream = await read(env, cwd, `rev-list --count ${quote("@{upstream}..HEAD")}`, context, [
0,
128,
]);
if (upstream) return Number(upstream);
if (!fork || !(await read(env, cwd, "remote", context)).trim()) return 0;
return Number(await read(env, cwd, `rev-list --count ${quote(`${fork}..HEAD`)}`, context));
}

export async function changes(
env: ExecutionEnv,
cwd: string,
Expand All @@ -131,9 +150,10 @@ export async function changes(
const stat = async (args: string, state: Stat["state"]) =>
numstat(await read(env, cwd, `diff -M --numstat -z ${args}`, context))
.map((change) => Object.assign(change, { state }));
const [branch, behind, committed, uncommitted, loose] = await Promise.all([
const [branch, behind, ahead, committed, uncommitted, loose] = await Promise.all([
read(env, cwd, "rev-parse --abbrev-ref HEAD", context),
against && read(env, cwd, `rev-list --count ${quote(`HEAD..${against}`)}`, context),
unpushed(env, cwd, against && range.base, context),
stat(`${range.base} ${range.head}`, "committed"),
stat(range.head, "uncommitted"),
untracked(env, cwd, context),
Expand All @@ -154,12 +174,36 @@ export async function changes(
...(name && name !== "HEAD" ? { branch: name } : {}),
cwd,
...range,
...(against ? { against } : {}),
...(Number(behind) ? { behind: Number(behind) } : {}),
...(ahead ? { ahead } : {}),
files,
...(loose.dirs.length ? { dirs: loose.dirs } : {}),
};
}

/**
* Fetches a lane's base when it is a remote-tracking branch, so `behind` can count what landed
* on it. Kept apart from `changes`, which runs every Diff refresh and must stay local.
*/
export async function fetchBase(
env: ExecutionEnv,
cwd: string,
base: string | undefined,
context: Context,
) {
const ref = base?.startsWith("refs/remotes/") && base.slice("refs/remotes/".length);
if (!ref) return;
const remote = (await read(env, cwd, "remote", context)).split("\n")
.filter((name) => name && ref.startsWith(`${name}/`))
.sort((a, b) => b.length - a.length)[0];
if (!remote) return;
const branch = ref.slice(remote.length + 1);
await git(env, `fetch -q ${quote(remote)} ${quote(`+refs/heads/${branch}:${base}`)}`, context, {
cwd,
});
}

/** `file` must be one `changes` listed; it is passed to git only as a quoted literal path. */
export async function patch(
env: ExecutionEnv,
Expand Down
6 changes: 5 additions & 1 deletion packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
STRONG,
} from "./captions";
import { instructions } from "./context";
import { changes, patch } from "./changes";
import { changes, fetchBase, patch } from "./changes";
import { type Desktop, desktop } from "./desktop";
import { HANDOFF, Handoffs } from "./handoffs";
import { type Images, images } from "./images";
Expand Down Expand Up @@ -402,6 +402,10 @@ export class Channel {
const { cwd, lane } = await this.#place(request.chat);
return changes(this.#options.env(cwd), cwd, this.#options.project, lane, context);
}
case "fetch": {
const { cwd, lane } = await this.#place(request.chat);
return fetchBase(this.#options.env(cwd), cwd, lane?.base, context);
}
case "patch": {
const { cwd, lane } = await this.#place(request.chat);
const { env, project } = this.#options;
Expand Down
Loading