feat(FN-803): add ahead/behind commit lists to Git Manager Remotes tab

- Add backend API routes for remote commit lists (local-ahead, remote-ahead, all commits by branch)
- Surface ahead/behind commit counts and expandable commit lists in Remotes tab UI
- Add expand/collapse rows showing commit hash, message, author, and relative date
- Style commit list rows with alternating backgrounds and monospace hashes
- Add comprehensive tests for API routes and GitManagerModal component
- Update README documentation for Git Manager Remotes tab
This commit is contained in:
gsxdsm
2026-04-03 22:02:57 -07:00
parent f1f98ba718
commit acfeea4fd3
9 changed files with 833 additions and 6 deletions

View File

@@ -3874,6 +3874,116 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/commits/ahead", () => {
it("returns commits ahead of upstream", async () => {
const res = await GET(buildApp(), "/api/git/commits/ahead");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
// Each commit should have the standard GitCommit shape
for (const commit of res.body) {
expect(commit).toHaveProperty("hash");
expect(commit).toHaveProperty("shortHash");
expect(commit).toHaveProperty("message");
expect(commit).toHaveProperty("author");
expect(commit).toHaveProperty("date");
expect(commit).toHaveProperty("parents");
}
});
it("returns empty array when no upstream is configured", async () => {
// In a worktree without upstream tracking, this should return []
const res = await GET(buildApp(), "/api/git/commits/ahead");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it("returns 400 when not a git repository", async () => {
const nonGitStore = createMockStore({
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(nonGitStore));
const res = await GET(app, "/api/git/commits/ahead");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Not a git repository");
});
});
describe("GET /git/remotes/:name/commits", () => {
it("returns commits for a valid remote", async () => {
// First, get remotes to find a valid name
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
const remoteName = remotesRes.body[0].name;
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
for (const commit of res.body) {
expect(commit).toHaveProperty("hash");
expect(commit).toHaveProperty("shortHash");
expect(commit).toHaveProperty("message");
expect(commit).toHaveProperty("author");
expect(commit).toHaveProperty("date");
expect(commit).toHaveProperty("parents");
}
}
});
it("returns 400 for invalid remote name", async () => {
const res = await GET(buildApp(), "/api/git/remotes/invalid;rm%20-rf%20/commits");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid remote name");
});
it("returns 400 for invalid ref parameter", async () => {
const res = await GET(buildApp(), "/api/git/remotes/origin/commits?ref=main;rm%20-rf");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid ref name");
});
it("respects limit parameter", async () => {
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
const remoteName = remotesRes.body[0].name;
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits?limit=3`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeLessThanOrEqual(3);
}
});
it("returns empty array for non-existent remote", async () => {
const res = await GET(buildApp(), "/api/git/remotes/nonexistent-remote-xyz/commits");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body).toHaveLength(0);
});
it("returns 400 when not a git repository", async () => {
const nonGitStore = createMockStore({
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
});
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(nonGitStore));
const res = await GET(app, "/api/git/remotes/origin/commits");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Not a git repository");
});
});
describe("GET /git/branches", () => {
it("returns branches array", async () => {
const res = await GET(buildApp(), "/api/git/branches");

View File

@@ -366,6 +366,123 @@ function getGitCommits(limit: number = 20, cwd?: string): GitCommit[] {
}
}
/**
* Validates a git ref name to prevent command injection.
* Refs include branch names, remote tracking branches (remote/branch), and tags.
* Must not contain shell metacharacters or start with dashes.
*/
function isValidGitRef(ref: string): boolean {
if (!ref || ref.length === 0) return false;
if (ref.startsWith("-")) return false;
if (/[;<>&|`$(){}[\]\r\n]/.test(ref)) return false;
if (/\s/.test(ref)) return false;
// Allow slashes for remote/branch format, dots, hyphens, underscores, alphanumerics
if (!/^[a-zA-Z0-9/_.@-]+$/.test(ref)) return false;
if (ref.includes("..")) return false;
if (ref.includes("~")) return false;
if (ref.includes("^")) return false;
if (ref.includes(":")) return false;
// Must not look like an option
if (ref.startsWith("--")) return false;
return true;
}
/**
* Get commits ahead of the upstream tracking branch (commits that would be pushed).
* Returns the list of local commits not yet present on the upstream.
* Returns an empty array if there is no upstream configured.
*/
function getAheadCommits(cwd?: string): GitCommit[] {
try {
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
// Check if an upstream is configured
try {
execSync("git rev-parse --abbrev-ref @{u}", execOptions);
} catch {
// No upstream configured
return [];
}
// Format: hash|shortHash|message|author|date|parents
const format = "%H|%h|%s|%an|%aI|%P";
const output = execSync(`git log @{u}..HEAD --pretty=format:"${format}"`, execOptions);
const commits: GitCommit[] = [];
for (const line of output.split("\n")) {
const parts = line.split("|");
if (parts.length < 5) continue;
const [hash, shortHash, message, author, date, parentsStr] = parts;
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
commits.push({
hash,
shortHash,
message: message || "",
author: author || "",
date: date || "",
parents,
});
}
return commits;
} catch {
return [];
}
}
/**
* Get recent commits reachable from a remote tracking ref.
* @param remoteRef The remote ref (e.g. "origin/main") to list commits for
* @param limit Maximum number of commits to return (default 10)
*/
function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string): GitCommit[] {
try {
if (!isValidGitRef(remoteRef)) {
throw new Error("Invalid remote ref");
}
// Verify the ref exists
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd };
try {
execSync(`git rev-parse --verify "${remoteRef}"`, execOptions);
} catch {
return [];
}
// Format: hash|shortHash|message|author|date|parents
const format = "%H|%h|%s|%an|%aI|%P";
const safeLimit = Math.min(Math.max(1, limit), 50);
const output = execSync(`git log --max-count=${safeLimit} --pretty=format:"${format}" "${remoteRef}"`, {
encoding: "utf-8",
timeout: 10000,
cwd,
});
const commits: GitCommit[] = [];
for (const line of output.split("\n")) {
const parts = line.split("|");
if (parts.length < 5) continue;
const [hash, shortHash, message, author, date, parentsStr] = parts;
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
commits.push({
hash,
shortHash,
message: message || "",
author: author || "",
date: date || "",
parents,
});
}
return commits;
} catch {
return [];
}
}
/**
* Get the diff for a specific commit.
* @param hash The commit hash
@@ -2555,6 +2672,107 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/git/commits/ahead
* Returns local commits ahead of the upstream tracking branch (commits that would be pushed).
* Response: Array of GitCommit objects (empty when no upstream is configured)
*/
router.get("/git/commits/ahead", (_req, res) => {
try {
const rootDir = store.getRootDir();
if (!isGitRepo(rootDir)) {
res.status(400).json({ error: "Not a git repository" });
return;
}
const commits = getAheadCommits(rootDir);
res.json(commits);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/git/remotes/:name/commits
* Returns recent commits for a specific remote tracking ref.
* Query: ?ref=branchName (defaults to HEAD of the remote's default branch)
* Query: ?limit=N (defaults to 10, max 50)
* Response: Array of GitCommit objects
*/
router.get("/git/remotes/:name/commits", (req, res) => {
try {
const rootDir = store.getRootDir();
if (!isGitRepo(rootDir)) {
res.status(400).json({ error: "Not a git repository" });
return;
}
const { name } = req.params;
if (!isValidBranchName(name)) {
res.status(400).json({ error: "Invalid remote name" });
return;
}
const ref = req.query.ref as string | undefined;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
// Build the full remote ref: if ref is given, use "remote/ref", otherwise use "remote/HEAD"
let remoteRef: string;
if (ref) {
if (!isValidGitRef(ref)) {
res.status(400).json({ error: "Invalid ref name" });
return;
}
// Strip any leading "refs/" or remote prefix the user might accidentally include
const cleanRef = ref.replace(/^refs\/(heads\/)?/, "");
// If the ref already starts with the remote name, use it as-is
if (cleanRef.startsWith(`${name}/`)) {
remoteRef = cleanRef;
} else {
remoteRef = `${name}/${cleanRef}`;
}
} else {
// Default: try remote/HEAD symbolic ref, fall back to remote/main, remote/master
try {
const headRef = execSync(`git symbolic-ref refs/remotes/${name}/HEAD`, {
encoding: "utf-8",
timeout: 5000,
cwd: rootDir,
}).trim();
// symbolic-ref returns full ref like refs/remotes/origin/main
remoteRef = headRef.replace(/^refs\/remotes\//, "");
} catch {
// Try common defaults
try {
execSync(`git rev-parse --verify "${name}/main"`, {
encoding: "utf-8",
timeout: 5000,
cwd: rootDir,
});
remoteRef = `${name}/main`;
} catch {
try {
execSync(`git rev-parse --verify "${name}/master"`, {
encoding: "utf-8",
timeout: 5000,
cwd: rootDir,
});
remoteRef = `${name}/master`;
} catch {
// Remote exists but no common branch found
res.json([]);
return;
}
}
}
}
const commits = getRemoteCommits(remoteRef, limit, rootDir);
res.json(commits);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/git/branches
* Returns all local branches with current indicator, remote tracking info, and last commit date.