Skip to content
Open
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
65 changes: 61 additions & 4 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Supports three output modes: default, verbose, and short.
*/

import { fetchRemoteTrackingTarget, resolveRemoteTrackingTarget } from "../lib/git-remote.js";
import { findWorkspaceRoot, loadConfig } from "../lib/config.js";
import { getFullGitStatus, getGitStatus } from "../lib/git.js";
import { info, error as logError, spinner } from "../lib/logger.js";
Expand Down Expand Up @@ -108,10 +109,31 @@ export interface RepoStatus {
files: GitFileStatus[];
/** Error message if status check failed */
error: string | null;
/** Warning shown when remote tracking refresh fails but local status is still available */
refreshWarning?: string | null;
/** Full git status output (for verbose mode) */
fullStatus?: string;
}

interface StatusCommandDependencies {
fetchRemoteTrackingTarget: typeof fetchRemoteTrackingTarget;
getFullGitStatus: typeof getFullGitStatus;
getGitStatus: typeof getGitStatus;
resolveRemoteTrackingTarget: typeof resolveRemoteTrackingTarget;
}

interface CheckRepoStatusOptions {
dependencies?: StatusCommandDependencies;
verbose?: boolean;
}

const defaultStatusCommandDependencies: StatusCommandDependencies = {
fetchRemoteTrackingTarget,
getFullGitStatus,
getGitStatus,
resolveRemoteTrackingTarget,
};

/**
* Parse git status porcelain output
*
Expand Down Expand Up @@ -209,11 +231,14 @@ const pathExists = async (path: string): Promise<boolean> => {
}
};

const createRefreshWarning = (error: string): string => `Remote tracking may be stale: ${error}`;

export const checkRepoStatus = async (
name: string,
path: string,
verbose = false,
options: CheckRepoStatusOptions = {},
): Promise<RepoStatus> => {
const { dependencies = defaultStatusCommandDependencies, verbose = false } = options;
const repoExists = await pathExists(path);
if (!repoExists) {
return {
Expand All @@ -222,11 +247,21 @@ export const checkRepoStatus = async (
files: [],
name,
path,
refreshWarning: null,
};
}

try {
const result = await getGitStatus(path);
let refreshWarning: string | null = null;
const trackingTarget = await dependencies.resolveRemoteTrackingTarget(path);
if (trackingTarget.ok) {
const fetchResult = await dependencies.fetchRemoteTrackingTarget(path, trackingTarget.target);
if (!fetchResult.ok) {
refreshWarning = createRefreshWarning(fetchResult.error);
}
}

const result = await dependencies.getGitStatus(path);

if (result.error) {
return {
Expand All @@ -235,14 +270,15 @@ export const checkRepoStatus = async (
files: [],
name,
path,
refreshWarning,
};
}

const parsed = parseGitStatus(result.output);

let fullStatus: string | undefined = undefined;
if (verbose) {
const fullResult = await getFullGitStatus(path);
const fullResult = await dependencies.getFullGitStatus(path);
if (fullResult.error) {
fullStatus = fullResult.error;
} else {
Expand All @@ -257,6 +293,7 @@ export const checkRepoStatus = async (
fullStatus,
name,
path,
refreshWarning,
};
} catch (error) {
let errorMessage = "Unknown error";
Expand All @@ -270,6 +307,7 @@ export const checkRepoStatus = async (
files: [],
name,
path,
refreshWarning: null,
};
}
};
Expand Down Expand Up @@ -299,7 +337,9 @@ export const checkAllRepos = (
reposToCheck.push({ name, path: absolutePath });
}

const statusPromises = reposToCheck.map((repo) => checkRepoStatus(repo.name, repo.path, verbose));
const statusPromises = reposToCheck.map((repo) =>
checkRepoStatus(repo.name, repo.path, { verbose }),
);

return Promise.all(statusPromises);
};
Expand Down Expand Up @@ -355,6 +395,11 @@ const getStatusColor = (isClean: boolean) => (hasError: boolean) => {
*/
const cyan = (text: string): string => `\u001B[36m${text}\u001B[0m`;

/**
* Helper to apply yellow color
*/
const yellow = (text: string): string => `\u001B[33m${text}\u001B[0m`;

/**
* Helper to apply bold
*/
Expand Down Expand Up @@ -414,6 +459,10 @@ export const formatRepoSection = (status: RepoStatus): string => {
section += ` ${parts.join(", ")}\n`;
}

if (status.refreshWarning) {
section += ` ${yellow(`Warning: ${status.refreshWarning}`)}\n`;
}

return section;
};

Expand Down Expand Up @@ -481,6 +530,10 @@ export const formatVerboseOutput = (statuses: RepoStatus[]): string => {
const colorFn = getStatusColor(true)(false);
output += ` ${colorFn("✓ Clean - No changes")}\n`;
}

if (status.refreshWarning) {
output += ` ${yellow(`Warning: ${status.refreshWarning}`)}\n`;
}
}

output += formatSummary(statuses);
Expand Down Expand Up @@ -543,6 +596,10 @@ export const formatShortLine = (status: RepoStatus): string => {
line += colorFn(`● ${status.files.length} changes (${parts.join(", ")})`);
}

if (status.refreshWarning) {
line += ` ${yellow("(remote tracking stale)")}`;
}

return line;
};

Expand Down
92 changes: 66 additions & 26 deletions src/lib/git-remote.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { exec } from "./git.ts";

export interface RemoteTrackingTarget {
upstream: string | null;
remote: string;
branch: string;
}

export type RemoteTrackingTargetResolution =
| { ok: true; target: RemoteTrackingTarget }
| { ok: false; error: string; upstream: string | null };

export interface RemoteChangeStatus {
repositoryId: string;
upstream: string | null;
Expand All @@ -11,10 +21,9 @@ export interface RemoteChangeStatus {
error?: string;
}

export async function checkRemoteChanges(
repositoryId: string,
export async function resolveRemoteTrackingTarget(
repoPath: string,
): Promise<RemoteChangeStatus> {
): Promise<RemoteTrackingTargetResolution> {
let upstream: string | null = null;
let remote: string | null = null;
let branch: string | null = null;
Expand All @@ -37,53 +46,84 @@ export async function checkRemoteChanges(
if (!remote || !branch) {
const fallback = await resolveRemoteAndBranch(repoPath);
if (!fallback.ok) {
return {
ahead: 0,
behind: 0,
branch: null,
error: fallback.error,
hasRemoteChanges: false,
remote: null,
repositoryId,
upstream,
};
return { error: fallback.error, ok: false, upstream };
}
({ remote } = fallback);
({ branch } = fallback);
}

if (!remote || !branch) {
return { error: "Unable to determine remote tracking branch", ok: false, upstream };
}

return {
ok: true,
target: {
branch,
remote,
upstream,
},
};
}

export async function fetchRemoteTrackingTarget(
repoPath: string,
target: RemoteTrackingTarget,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await exec(
[
"fetch",
"--prune",
target.remote,
`+refs/heads/${target.branch}:refs/remotes/${target.remote}/${target.branch}`,
],
repoPath,
);
return { ok: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Remote fetch failed",
ok: false,
};
}
}

export async function checkRemoteChanges(
repositoryId: string,
repoPath: string,
): Promise<RemoteChangeStatus> {
const resolution = await resolveRemoteTrackingTarget(repoPath);
if (!resolution.ok) {
return {
ahead: 0,
behind: 0,
branch: null,
error: "Unable to determine remote tracking branch",
error: resolution.error,
hasRemoteChanges: false,
remote: null,
repositoryId,
upstream,
upstream: resolution.upstream,
};
}

try {
await exec(
["fetch", "--prune", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`],
repoPath,
);
} catch (error) {
const message = error instanceof Error ? error.message : "Remote fetch failed";
const { target } = resolution;
const fetchResult = await fetchRemoteTrackingTarget(repoPath, target);
if (!fetchResult.ok) {
return {
ahead: 0,
behind: 0,
branch,
error: message,
branch: target.branch,
error: fetchResult.error,
hasRemoteChanges: false,
remote,
remote: target.remote,
repositoryId,
upstream,
upstream: target.upstream,
};
}

const { branch, remote, upstream } = target;

try {
const compareRef = `refs/remotes/${remote}/${branch}`;
const result = await exec(
Expand Down
Loading
Loading