Show workspace commits clearly in Changes (#2350)
* feat(commits): distinguish workspace history from base commits Keep every workspace commit visible while bounding base history to ten context commits, and make push and base state readable in the commit rail. * fix(commits): preserve history when base refs disappear Fall back to recent HEAD history for stale saved bases, and reject truncated git output instead of presenting an incomplete commit list. * fix(commits): classify history against the current base Use the furthest-ahead local or remote base for classification, and re-infer base history when saved worktree metadata points to a deleted branch.
This commit is contained in:
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listCheckoutCommits } from "./checkout-git.js";
|
||||
import { writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -73,6 +74,9 @@ describe("listCheckoutCommits", () => {
|
||||
expect(commits[0]?.isOnRemote).toBe(false);
|
||||
expect(commits[1]?.isOnRemote).toBe(true);
|
||||
expect(commits[2]?.isOnRemote).toBe(true);
|
||||
expect(commits[0]?.isOnBase).toBe(false);
|
||||
expect(commits[1]?.isOnBase).toBe(false);
|
||||
expect(commits[2]?.isOnBase).toBe(true);
|
||||
|
||||
expect(commits[0]?.files).toEqual([
|
||||
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
||||
@@ -92,6 +96,26 @@ describe("listCheckoutCommits", () => {
|
||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
expect(baseRef).toBeNull();
|
||||
expect(commits.map((entry) => entry.subject)).toEqual(["initial"]);
|
||||
expect(commits[0]?.isOnBase).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps recent history when the saved base branch no longer exists", async () => {
|
||||
const { repoDir, tempDir } = initRepoOnMain();
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const worktreeDir = join(worktreesRoot, "repo-hash", "feature");
|
||||
mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true });
|
||||
git(["worktree", "add", "-b", "feature", worktreeDir], repoDir);
|
||||
commitFile(worktreeDir, "feature.txt", "feature\n", "Feature work");
|
||||
writePaseoWorktreeMetadata(worktreeDir, { baseRefName: "deleted-base" });
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({
|
||||
cwd: worktreeDir,
|
||||
context: { worktreesRoot },
|
||||
});
|
||||
|
||||
expect(baseRef).toBe("main");
|
||||
expect(commits.map((entry) => entry.subject)).toEqual(["Feature work", "initial"]);
|
||||
expect(commits.map((entry) => entry.isOnBase)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("marks all commits local-only when there is no remote", async () => {
|
||||
@@ -122,19 +146,77 @@ describe("listCheckoutCommits", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits history and unpushed classification to the 20 most recent commits", async () => {
|
||||
it("keeps local base commits out of workspace history when the local base is ahead", async () => {
|
||||
const { repoDir, tempDir } = initRepoOnMain();
|
||||
addBareRemote(repoDir, tempDir);
|
||||
git(["push", "-u", "origin", "main"], repoDir);
|
||||
commitFile(repoDir, "local-base.txt", "base\n", "Local base work");
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "Feature work");
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([
|
||||
{ subject: "Feature work", isOnBase: false },
|
||||
{ subject: "Local base work", isOnBase: true },
|
||||
{ subject: "initial", isOnBase: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows every workspace commit followed by at most 10 base commits", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
for (let index = 1; index <= 14; index += 1) {
|
||||
commitFile(repoDir, "base-history.txt", `${index}\n`, `Base ${index}`);
|
||||
}
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
for (let index = 1; index <= 24; index += 1) {
|
||||
commitFile(repoDir, "workspace-history.txt", `${index}\n`, `Workspace ${index}`);
|
||||
}
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits).toHaveLength(34);
|
||||
expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true);
|
||||
expect(commits.slice(0, 24).map((entry) => entry.subject)).toEqual(
|
||||
Array.from({ length: 24 }, (_, index) => `Workspace ${24 - index}`),
|
||||
);
|
||||
expect(commits.slice(0, 24).every((entry) => entry.isOnBase === false)).toBe(true);
|
||||
expect(commits.slice(24).map((entry) => entry.subject)).toEqual(
|
||||
Array.from({ length: 10 }, (_, index) => `Base ${14 - index}`),
|
||||
);
|
||||
expect(commits.slice(24).every((entry) => entry.isOnBase === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("starts base context at the fork point when the base branch has advanced", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
commitFile(repoDir, "shared.txt", "shared\n", "Shared base");
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "Feature work");
|
||||
git(["checkout", "main"], repoDir);
|
||||
commitFile(repoDir, "newer-base.txt", "newer\n", "Newer base");
|
||||
git(["checkout", "feature"], repoDir);
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits.map(({ subject, isOnBase }) => ({ subject, isOnBase }))).toEqual([
|
||||
{ subject: "Feature work", isOnBase: false },
|
||||
{ subject: "Shared base", isOnBase: true },
|
||||
{ subject: "initial", isOnBase: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits base-branch history to 10 commits", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
for (let index = 1; index <= 14; index += 1) {
|
||||
commitFile(repoDir, "history.txt", `${index}\n`, `Commit ${index}`);
|
||||
}
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits).toHaveLength(20);
|
||||
expect(commits.every((entry) => entry.isOnRemote === false)).toBe(true);
|
||||
expect(commits.map((entry) => entry.subject)).toEqual(
|
||||
Array.from({ length: 20 }, (_, index) => `Commit ${24 - index}`),
|
||||
Array.from({ length: 10 }, (_, index) => `Commit ${14 - index}`),
|
||||
);
|
||||
expect(commits.every((entry) => entry.isOnBase === true)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows merged branch commits and compares the merge against its first parent", async () => {
|
||||
|
||||
@@ -1988,8 +1988,9 @@ export async function getCheckoutStatus(
|
||||
};
|
||||
}
|
||||
|
||||
// The explorer is a recent-history view, not a full repository log.
|
||||
const MAX_CHECKOUT_COMMITS = 20;
|
||||
// Workspace history stays complete; base history is bounded context until the
|
||||
// commits list supports paging older base commits.
|
||||
const CHECKOUT_BASE_COMMIT_LIMIT = 10;
|
||||
// Bytes git emits between fields/records. We split parsed output on these.
|
||||
const COMMIT_FIELD_SEPARATOR = "\x00";
|
||||
const COMMIT_RECORD_SEPARATOR = "\x1e";
|
||||
@@ -2009,6 +2010,12 @@ interface ParsedCheckoutCommit {
|
||||
files: CheckoutCommitFile[];
|
||||
}
|
||||
|
||||
interface CheckoutCommitLogInput {
|
||||
cwd: string;
|
||||
revision: string;
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined {
|
||||
switch (letter) {
|
||||
case "A":
|
||||
@@ -2133,14 +2140,11 @@ function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] {
|
||||
|
||||
// Returns commits reachable from HEAD that are not reachable from any remote ref.
|
||||
async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Promise<Set<string>> {
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", `--max-count=${MAX_CHECKOUT_COMMITS}`, "HEAD", "--not", "--remotes"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
},
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-list", "HEAD", "--not", "--remotes"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
return new Set(
|
||||
stdout
|
||||
.split("\n")
|
||||
@@ -2149,58 +2153,104 @@ async function getUnpushedCommitShas(cwd: string, context?: CheckoutContext): Pr
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the current branch's 20 most recent commits, newest first, each flagged
|
||||
* local-only vs on-remote with per-commit file +/- stats.
|
||||
*/
|
||||
async function getCheckoutCommitRecords({
|
||||
cwd,
|
||||
revision,
|
||||
maxCount,
|
||||
}: CheckoutCommitLogInput): Promise<ParsedCheckoutCommit[]> {
|
||||
const args = [
|
||||
"log",
|
||||
revision,
|
||||
"--diff-merges=first-parent",
|
||||
`--format=${COMMIT_LOG_FORMAT}`,
|
||||
"--raw",
|
||||
"--numstat",
|
||||
"-M",
|
||||
];
|
||||
if (maxCount !== undefined) {
|
||||
args.splice(2, 0, `--max-count=${maxCount}`);
|
||||
}
|
||||
|
||||
const result = await runGitCommand(args, { cwd, envOverlay: READ_ONLY_GIT_ENV });
|
||||
if (result.truncated) {
|
||||
throw new Error("Commit history exceeded the git output limit");
|
||||
}
|
||||
return parseCheckoutCommitRecords(result.stdout);
|
||||
}
|
||||
|
||||
export interface CheckoutCommitsResult {
|
||||
baseRef: string | null;
|
||||
commits: CheckoutCommit[];
|
||||
}
|
||||
|
||||
async function tryResolveCheckoutCommitsBaseRef(
|
||||
cwd: string,
|
||||
baseRef: string | null,
|
||||
currentBranch: string,
|
||||
): Promise<string | null> {
|
||||
if (!baseRef) {
|
||||
return null;
|
||||
}
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||
return null;
|
||||
}
|
||||
return resolveMostAheadBaseRef(cwd, normalizedBaseRef).catch(() => null);
|
||||
}
|
||||
|
||||
export async function listCheckoutCommits({
|
||||
cwd,
|
||||
context,
|
||||
}: {
|
||||
cwd: string;
|
||||
context?: CheckoutContext;
|
||||
}): Promise<CheckoutCommitsResult> {
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch) {
|
||||
return { baseRef: null, commits: [] };
|
||||
}
|
||||
|
||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
|
||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const normalizedBaseRef = resolvedBaseRef ? normalizeLocalBranchRefName(resolvedBaseRef) : null;
|
||||
let comparisonBaseRef: string | null = null;
|
||||
if (resolvedBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) {
|
||||
try {
|
||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef);
|
||||
} catch {
|
||||
// History does not depend on the configured base being available.
|
||||
}
|
||||
let comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
|
||||
cwd,
|
||||
resolvedBaseRef,
|
||||
currentBranch,
|
||||
);
|
||||
if (!comparisonBaseRef && normalizedBaseRef && normalizedBaseRef !== currentBranch) {
|
||||
// Saved worktree metadata can outlive a renamed or deleted base branch.
|
||||
comparisonBaseRef = await tryResolveCheckoutCommitsBaseRef(
|
||||
cwd,
|
||||
await resolveBaseRef(cwd),
|
||||
currentBranch,
|
||||
);
|
||||
}
|
||||
|
||||
// Single pass: `--raw` carries the status letter, `--numstat` the +/- counts.
|
||||
// (`--name-status` cannot be combined with `--numstat` — git emits only one.)
|
||||
const logResult = await runGitCommand(
|
||||
[
|
||||
"log",
|
||||
"HEAD",
|
||||
"--diff-merges=first-parent",
|
||||
`--max-count=${MAX_CHECKOUT_COMMITS}`,
|
||||
`--format=${COMMIT_LOG_FORMAT}`,
|
||||
"--raw",
|
||||
"--numstat",
|
||||
"-M",
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
let workspaceRecords: ParsedCheckoutCommit[] = [];
|
||||
let baseRevision = "HEAD";
|
||||
if (comparisonBaseRef) {
|
||||
const [records, mergeBase] = await Promise.all([
|
||||
getCheckoutCommitRecords({ cwd, revision: `${comparisonBaseRef}..HEAD` }),
|
||||
tryResolveMergeBase(cwd, comparisonBaseRef),
|
||||
]);
|
||||
workspaceRecords = records;
|
||||
baseRevision = mergeBase ?? "";
|
||||
}
|
||||
|
||||
const records = parseCheckoutCommitRecords(logResult.stdout);
|
||||
const baseRecords = baseRevision
|
||||
? await getCheckoutCommitRecords({
|
||||
cwd,
|
||||
revision: baseRevision,
|
||||
maxCount: CHECKOUT_BASE_COMMIT_LIMIT,
|
||||
})
|
||||
: [];
|
||||
const records = [...workspaceRecords, ...baseRecords];
|
||||
if (records.length === 0) {
|
||||
return { baseRef: comparisonBaseRef, commits: [] };
|
||||
}
|
||||
|
||||
const unpushedShas = await getUnpushedCommitShas(cwd);
|
||||
const workspaceShas = new Set(workspaceRecords.map((record) => record.sha));
|
||||
|
||||
const commits = records.map((record) => ({
|
||||
sha: record.sha,
|
||||
@@ -2209,6 +2259,7 @@ export async function listCheckoutCommits({
|
||||
authorName: record.authorName,
|
||||
authorDate: record.authorDate,
|
||||
isOnRemote: !unpushedShas.has(record.sha),
|
||||
isOnBase: !workspaceShas.has(record.sha),
|
||||
files: record.files,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user