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
2 changes: 2 additions & 0 deletions changelog.d/3445.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(skills): git status を読めなかったとき skills の計画と CLI が「変更あり」ではなく「不明」と出す** (#3445): install / update / uninstall の計画に `workingTreeUnknown`(読めたときはキー無し)と警告 `SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN` を足し、画面(ja / en)と CLI(`[working tree status unknown]`)で未コミットの変更ありと区別する。clean とは表示しない
14 changes: 7 additions & 7 deletions docs/module-reference.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions locales/en/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"workingTree": "Working tree",
"workingTreeClean": "No uncommitted changes",
"workingTreeDirty": "Has uncommitted changes",
"workingTreeStatusUnknown": "Could not check for uncommitted changes",
"workingTreeUnknown": "Not inspected",
"sessionRunning": "A session is currently running in this worktree.",
"branchFreshnessNotice": "The branch shown here comes from the last worktree scan. CommandMate resolves the live branch and commit when it builds the install plan, and reports any difference there."
Expand Down Expand Up @@ -215,6 +216,7 @@
"unbornHead": "This worktree has no commit yet.",
"headUnresolved": "The current commit could not be resolved.",
"workingTreeDirty": "This worktree has uncommitted changes, so the install would mix with work in progress.",
"workingTreeStatusUnknown": "git status could not be read, so this worktree may have uncommitted changes that the install would mix with.",
"pathGitIgnored": "Some destination paths are ignored by git, so the installed files would not show up in git status.",
"diffTruncated": "Some diffs were too large to show in full.",
"binaryContent": "Some files are binary, so no diff can be shown for them.",
Expand Down
2 changes: 2 additions & 0 deletions locales/ja/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"workingTree": "作業ツリー",
"workingTreeClean": "未コミットの変更なし",
"workingTreeDirty": "未コミットの変更あり",
"workingTreeStatusUnknown": "未コミットの変更の有無を確認できませんでした",
"workingTreeUnknown": "未確認",
"sessionRunning": "このworktreeではセッションが稼働中です。",
"branchFreshnessNotice": "ここに表示しているブランチは直近のworktree同期時点の値です。CommandMateはinstall plan生成時に現在のブランチとコミットを解決し、差異があればそこで報告します。"
Expand Down Expand Up @@ -215,6 +216,7 @@
"unbornHead": "このworktreeにはまだコミットがありません。",
"headUnresolved": "現在のコミットを解決できませんでした。",
"workingTreeDirty": "このworktreeには未コミットの変更があるため、導入内容が作業中の変更と混ざります。",
"workingTreeStatusUnknown": "git status を読めなかったため、このworktreeに未コミットの変更があるか分かりません。変更がある場合、導入内容が作業中の変更と混ざります。",
"pathGitIgnored": "一部の書き込み先はgitのignore対象です。導入したファイルはgit statusに現れません。",
"diffTruncated": "一部の差分は大きすぎるため省略されています。",
"binaryContent": "一部のファイルはバイナリのため差分を表示できません。",
Expand Down
18 changes: 15 additions & 3 deletions src/cli/commands/skill-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ export function formatInstallRoots(roots: readonly string[] | undefined, primary
return roots && roots.length > 1 ? roots.join(', ') : primary;
}

/**
* The working-tree mark after the branch (Issue #3445). An unread `git status`
* is flagged as unknown rather than dirty, and never as clean.
*/
export function formatWorkingTreeMark(target: {
workingTreeDirty: boolean;
workingTreeUnknown?: boolean;
}): string {
if (target.workingTreeUnknown) return ' [working tree status unknown]';
return target.workingTreeDirty ? ' [working tree dirty]' : '';
}

/** The install preview: what lands, where, at what risk, and what stands in the way. */
export function formatInstallPlan(plan: SkillInstallPlan): string {
const { skill, target, stats } = plan;
Expand All @@ -121,7 +133,7 @@ export function formatInstallPlan(plan: SkillInstallPlan): string {
skill.summary,
'',
`Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`,
`Branch: ${target.branch ?? `(${target.headState})`}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`,
`Branch: ${target.branch ?? `(${target.headState})`}${formatWorkingTreeMark(target)}`,
`Install root: ${formatInstallRoots(target.installRoots, target.installRoot)}`,
`Risk: ${skill.effectiveRisk} — ${skill.riskRationale}`,
`Compatibility: ${skill.compatibility.commandmate.status} — ${skill.compatibility.commandmate.message}`,
Expand Down Expand Up @@ -173,7 +185,7 @@ export function formatUpdatePlan(plan: SkillUpdatePlan): string {
`Update plan: ${skill.name} (${skill.id}) ${update.fromVersion} -> ${update.toVersion}`,
'',
`Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`,
`Branch: ${target.branch ?? `(${target.headState})`}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`,
`Branch: ${target.branch ?? `(${target.headState})`}${formatWorkingTreeMark(target)}`,
`Install root: ${formatInstallRoots(target.installRoots, target.installRoot)}`,
`Risk: ${securityDiff.risk.from.effective} -> ${securityDiff.risk.to.effective}${securityDiff.risk.increased ? ' [RISK INCREASE]' : ''}`,
`Compatibility: ${skill.compatibility.commandmate.status} — ${skill.compatibility.commandmate.message}`,
Expand Down Expand Up @@ -248,7 +260,7 @@ export function formatUninstallPlan(plan: SkillUninstallPlan): string {
`Uninstall plan: ${skill.id} ${skill.version}`,
'',
`Target: ${target.repositoryName} / ${target.worktreeName} (${target.worktreeId})`,
`Branch: ${target.branch ?? '(detached)'}${target.workingTreeDirty ? ' [working tree dirty]' : ''}`,
`Branch: ${target.branch ?? '(detached)'}${formatWorkingTreeMark(target)}`,
`Install root: ${target.installRoot}`,
`Risk: ${skill.effectiveRisk}`,
`Files: ${stats.removable} removable, ${stats.modified} locally modified, ${stats.missing} missing, ${stats.unknown} unmanaged, ${stats.irregular} irregular`,
Expand Down
6 changes: 6 additions & 0 deletions src/cli/types/api-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,8 @@ export interface SkillInstallPlan {
branch: string | null;
headState: string;
workingTreeDirty: boolean;
/** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */
workingTreeUnknown?: boolean;
/** Repository-relative; the server never serves a machine-absolute path. */
installRoot: string;
/** Every root the package is placed into, primary first (#1460). */
Expand Down Expand Up @@ -1653,6 +1655,8 @@ export interface SkillUninstallPlan {
repositoryName: string;
branch: string | null;
workingTreeDirty: boolean;
/** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */
workingTreeUnknown?: boolean;
installRoot: string;
};
skill: { id: string; version: string; effectiveRisk: string };
Expand Down Expand Up @@ -1704,6 +1708,8 @@ export interface SkillUpdatePlan {
branch: string | null;
headState: string;
workingTreeDirty: boolean;
/** `git status` could not be read; `workingTreeDirty` is the fail-safe guess (#3445). */
workingTreeUnknown?: boolean;
/** Repository-relative; the server never serves a machine-absolute path. */
installRoot: string;
/** Every recorded root the update rewrites, primary first (#1460). */
Expand Down
12 changes: 10 additions & 2 deletions src/components/skills/SkillPlanPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export function SkillInstallPlanPreview({ plan }: SkillInstallPlanPreviewProps)
{t(HEAD_STATE_LABEL_KEY[target.headState] ?? 'plan.headState.unknown')}
</Field>
<Field label={t('target.workingTree')}>
{target.workingTreeDirty ? t('target.workingTreeDirty') : t('target.workingTreeClean')}
{target.workingTreeUnknown
? t('target.workingTreeStatusUnknown')
: target.workingTreeDirty
? t('target.workingTreeDirty')
: t('target.workingTreeClean')}
</Field>
<Field label={t('plan.installRoot')}>
<span className="break-all font-mono text-xs">
Expand Down Expand Up @@ -367,7 +371,11 @@ export function SkillUninstallPlanPreview({ plan }: SkillUninstallPlanPreviewPro
<span className="break-all font-mono text-xs">{target.installRoot}</span>
</Field>
<Field label={t('target.workingTree')}>
{target.workingTreeDirty ? t('target.workingTreeDirty') : t('target.workingTreeClean')}
{target.workingTreeUnknown
? t('target.workingTreeStatusUnknown')
: target.workingTreeDirty
? t('target.workingTreeDirty')
: t('target.workingTreeClean')}
</Field>
</dl>

Expand Down
1 change: 1 addition & 0 deletions src/components/skills/skill-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export const PREVIEW_WARNING_LABEL_KEY: Record<string, string> = {
SKILL_PREVIEW_UNBORN_HEAD: 'plan.warning.unbornHead',
SKILL_PREVIEW_HEAD_UNRESOLVED: 'plan.warning.headUnresolved',
SKILL_PREVIEW_WORKING_TREE_DIRTY: 'plan.warning.workingTreeDirty',
SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN: 'plan.warning.workingTreeStatusUnknown',
SKILL_PREVIEW_PATH_GIT_IGNORED: 'plan.warning.pathGitIgnored',
SKILL_PREVIEW_DIFF_TRUNCATED: 'plan.warning.diffTruncated',
SKILL_PREVIEW_BINARY_CONTENT: 'plan.warning.binaryContent',
Expand Down
6 changes: 6 additions & 0 deletions src/lib/skills/install-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ export interface SkillPlanTargetDto {
headState: SkillGitTargetState['headState'];
headCommit: string | null;
workingTreeDirty: boolean;
/**
* `git status` could not be read (Issue #3445). `workingTreeDirty` is then
* true as the fail-safe guess; present only when true.
*/
workingTreeUnknown?: boolean;
/** Repository-relative primary install root (`.agents/skills/<id>`). */
installRoot: string;
/** Every repository-relative root the package will be placed into, primary first (#1460). */
Expand Down Expand Up @@ -791,6 +796,7 @@ export async function createSkillInstallPlan(
headState: git.headState,
headCommit: git.headCommit,
workingTreeDirty: git.dirty,
...(git.dirtyUnknown ? { workingTreeUnknown: true } : {}),
installRoot: primary.preview.installRoot,
installRoots,
currentTreeHash: primary.preview.currentTreeHash,
Expand Down
25 changes: 24 additions & 1 deletion src/lib/skills/preview-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const SkillPreviewWarning = {
HEAD_UNRESOLVED: 'SKILL_PREVIEW_HEAD_UNRESOLVED',
/** The worktree has uncommitted changes. */
WORKING_TREE_DIRTY: 'SKILL_PREVIEW_WORKING_TREE_DIRTY',
/** `git status` could not be read, so uncommitted changes may exist (Issue #3445). */
WORKING_TREE_STATUS_UNKNOWN: 'SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN',
/** At least one destination path is ignored or excluded by git. */
PATH_GIT_IGNORED: 'SKILL_PREVIEW_PATH_GIT_IGNORED',
/** At least one diff body was cut short. */
Expand Down Expand Up @@ -175,6 +177,11 @@ export interface SkillGitTargetState {
headCommit: string | null;
/** The worktree has uncommitted changes (also true when `git status` could not be read). */
dirty: boolean;
/**
* `git status` could not be read, so `dirty` is the fail-safe guess rather
* than a fact (Issue #3445). Present only when true, like `GitStatus.statusUnknown`.
*/
dirtyUnknown?: boolean;
}

/** A file the package would write, as the diff builder needs it. */
Expand Down Expand Up @@ -488,6 +495,9 @@ export async function readSkillGitTargetState(
// read as clean. This flag only drives the advisory WORKING_TREE_DIRTY
// warning and the target display, so the fail-safe side is "dirty".
dirty: status === null || status.length > 0,
// Issue #3445: say the guess is a guess, so the display can tell "has
// changes" from "could not check". Absent on success (shape unchanged).
...(status === null ? { dirtyUnknown: true } : {}),
};
}

Expand Down Expand Up @@ -812,6 +822,18 @@ export function buildSkillPreviewDiff(input: BuildPreviewInput): SkillPreviewDif
};
}

/**
* The working-tree caveat for a target (Issue #3445): an unread status is still
* a caveat (fail-safe), but worded as "could not check" rather than "dirty".
*/
export function workingTreeWarning(
git: Pick<SkillGitTargetState, 'dirty' | 'dirtyUnknown'>
): SkillPreviewWarningCode | null {
if (git.dirtyUnknown) return SkillPreviewWarning.WORKING_TREE_STATUS_UNKNOWN;
if (git.dirty) return SkillPreviewWarning.WORKING_TREE_DIRTY;
return null;
}

function collectWarnings(
input: BuildPreviewInput,
entries: readonly SkillDiffEntry[],
Expand All @@ -822,7 +844,8 @@ function collectWarnings(
if (input.git.headState === 'detached') warnings.push(SkillPreviewWarning.DETACHED_HEAD);
if (input.git.headState === 'unborn') warnings.push(SkillPreviewWarning.UNBORN_HEAD);
if (input.git.headState === 'unknown') warnings.push(SkillPreviewWarning.HEAD_UNRESOLVED);
if (input.git.dirty) warnings.push(SkillPreviewWarning.WORKING_TREE_DIRTY);
const dirtyWarning = workingTreeWarning(input.git);
if (dirtyWarning) warnings.push(dirtyWarning);
if (entries.some((entry) => entry.gitIgnored)) warnings.push(SkillPreviewWarning.PATH_GIT_IGNORED);
if (stats.truncatedFiles > 0) warnings.push(SkillPreviewWarning.DIFF_TRUNCATED);
if (stats.binaryFiles > 0) warnings.push(SkillPreviewWarning.BINARY_CONTENT);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/skills/uninstall-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,11 @@ export interface SkillUninstallTargetDto {
headState: SkillGitTargetState['headState'];
headCommit: string | null;
workingTreeDirty: boolean;
/**
* `git status` could not be read (Issue #3445). `workingTreeDirty` is then
* true as the fail-safe guess; present only when true.
*/
workingTreeUnknown?: boolean;
installRoot: string;
currentTreeHash: string;
}
Expand Down Expand Up @@ -674,6 +679,7 @@ export function createSkillUninstallPlan(
headState: input.git.headState,
headCommit: input.git.headCommit,
workingTreeDirty: input.git.dirty,
...(input.git.dirtyUnknown ? { workingTreeUnknown: true } : {}),
installRoot: assessment.installRoot,
currentTreeHash: assessment.currentTreeHash,
},
Expand Down
10 changes: 9 additions & 1 deletion src/lib/skills/update-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import {
SKILL_DIFF_MAX_TOTAL_BYTES,
SkillPreviewWarning,
workingTreeWarning,
buildUnifiedDiff,
computeSkillTreeHash,
isBinaryContent,
Expand Down Expand Up @@ -260,6 +261,11 @@ export interface SkillUpdateTargetDto {
headState: SkillGitTargetState['headState'];
headCommit: string | null;
workingTreeDirty: boolean;
/**
* `git status` could not be read (Issue #3445). `workingTreeDirty` is then
* true as the fail-safe guess; present only when true.
*/
workingTreeUnknown?: boolean;
/** Primary install root; the binding's tree hashes are anchored here. */
installRoot: string;
/** Every recorded root, primary first. The receipt's set is authoritative (#1460). */
Expand Down Expand Up @@ -1042,7 +1048,8 @@ export function createSkillUpdatePlan(input: CreateSkillUpdatePlanInput): SkillU
if (input.git.headState === 'detached') warnings.push(SkillPreviewWarning.DETACHED_HEAD);
if (input.git.headState === 'unborn') warnings.push(SkillPreviewWarning.UNBORN_HEAD);
if (input.git.headState === 'unknown') warnings.push(SkillPreviewWarning.HEAD_UNRESOLVED);
if (input.git.dirty) warnings.push(SkillPreviewWarning.WORKING_TREE_DIRTY);
const dirtyWarning = workingTreeWarning(input.git);
if (dirtyWarning) warnings.push(dirtyWarning);
if (anyBinary) warnings.push(SkillPreviewWarning.BINARY_CONTENT);
if (anyTruncated) warnings.push(SkillPreviewWarning.DIFF_TRUNCATED);
if (roots.some((root) => root.existing.truncated)) {
Expand Down Expand Up @@ -1102,6 +1109,7 @@ export function createSkillUpdatePlan(input: CreateSkillUpdatePlanInput): SkillU
headState: input.git.headState,
headCommit: input.git.headCommit,
workingTreeDirty: input.git.dirty,
...(input.git.dirtyUnknown ? { workingTreeUnknown: true } : {}),
installRoot: primary.rel,
installRoots: roots.map((root) => root.rel),
roots: roots.map((root) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Issue #3445: the plan previews tell "has uncommitted changes" apart from
* "could not check" (git status unread), and never show the latter as clean.
*
* @vitest-environment jsdom
*/

import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('next-intl', () => ({
useTranslations:
(namespace?: string) =>
(key: string, params?: Record<string, string | number>) => {
const full = namespace ? `${namespace}.${key}` : key;
if (!params) return full;
const rendered = Object.entries(params)
.map(([name, value]) => `${name}=${value}`)
.join(',');
return `${full}(${rendered})`;
},
useLocale: () => 'en',
}));

import {
SkillInstallPlanPreview,
SkillUninstallPlanPreview,
} from '@/components/skills/SkillPlanPreview';
import { makeInstallPlan, makeUninstallPlan } from './fixtures';

describe('SkillPlanPreview working tree (Issue #3445)', () => {
it('install: shows "status unknown" and its caveat, not dirty or clean', () => {
const base = makeInstallPlan();
render(
<SkillInstallPlanPreview
plan={{
...base,
warnings: ['SKILL_PREVIEW_WORKING_TREE_STATUS_UNKNOWN'],
target: { ...base.target, workingTreeDirty: true, workingTreeUnknown: true },
}}
/>
);

expect(screen.getByText('skills.target.workingTreeStatusUnknown')).toBeInTheDocument();
expect(screen.getByText('skills.plan.warning.workingTreeStatusUnknown')).toBeInTheDocument();
expect(screen.queryByText('skills.target.workingTreeDirty')).toBeNull();
expect(screen.queryByText('skills.target.workingTreeClean')).toBeNull();
});

it('install: dirty still reads as dirty', () => {
const base = makeInstallPlan();
render(
<SkillInstallPlanPreview plan={{ ...base, target: { ...base.target, workingTreeDirty: true } }} />
);

expect(screen.getByText('skills.target.workingTreeDirty')).toBeInTheDocument();
});

it('uninstall: shows "status unknown"', () => {
const base = makeUninstallPlan();
render(
<SkillUninstallPlanPreview
plan={{ ...base, target: { ...base.target, workingTreeDirty: true, workingTreeUnknown: true } }}
/>
);

expect(screen.getByText('skills.target.workingTreeStatusUnknown')).toBeInTheDocument();
expect(screen.queryByText('skills.target.workingTreeDirty')).toBeNull();
});
});
25 changes: 25 additions & 0 deletions tests/unit/lib/git/git-status-unknown-3435.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,28 @@ describe('readSkillGitTargetState when status cannot be read (Issue #3435)', ()
expect((await readSkillGitTargetState('/repo')).dirty).toBe(true);
});
});

describe('readSkillGitTargetState marks an unread status as unknown (Issue #3445)', () => {
const sha = 'a'.repeat(40);

beforeEach(() => {
vi.clearAllMocks();
});

it('sets dirtyUnknown alongside the fail-safe dirty', async () => {
stubGit(timeoutError(), { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` });

const state = await readSkillGitTargetState('/repo');

expect(state.dirty).toBe(true);
expect(state.dirtyUnknown).toBe(true);
});

it('leaves the key out on success (clean or dirty)', async () => {
stubGit({ stdout: '' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` });
expect('dirtyUnknown' in (await readSkillGitTargetState('/repo'))).toBe(false);

stubGit({ stdout: ' M a.ts\n' }, { 'symbolic-ref': 'main\n', 'rev-parse HEAD': `${sha}\n` });
expect('dirtyUnknown' in (await readSkillGitTargetState('/repo'))).toBe(false);
});
});
Loading
Loading