fix: preserve staged and unstaged changes per file (#3104)

This commit is contained in:
José Almeida
2026-08-30 13:26:18 +00:00
committed by GitHub
parent c946140812
commit cc2ee0b43d
48 changed files with 2075 additions and 112 deletions
@@ -70,13 +70,16 @@ func (wt *WorkspaceTracker) enrichWithDiffData(
if err := wt.enrichWithUnstagedDiffBudget(ctx, update, "HEAD", prior, &budget); err != nil {
return err
}
if err := wt.enrichMixedUnstagedDiffsBudget(ctx, update, prior, &budget); err != nil {
return err
}
if err := wt.enrichWithStagedDiffBudget(ctx, update, "HEAD", prior, &budget); err != nil {
return err
}
if err := wt.enrichUntrackedFileDiffsBudget(ctx, update, &budget); err != nil {
return err
}
return carryForwardFileDiffs(ctx, update, prior)
return carryForwardFileDiffsBudget(ctx, update, prior, &budget)
}
// enrichWithBranchDiff computes the total additions/deletions for the entire branch
@@ -168,6 +171,12 @@ func totalDiffBytes(ctx context.Context, update *types.GitStatusUpdate) (int64,
return 0, err
}
total += int64(len(fi.Diff))
if fi.StagedChange != nil {
total += int64(len(fi.StagedChange.Diff))
}
if fi.UnstagedChange != nil {
total += int64(len(fi.UnstagedChange.Diff))
}
}
return total, nil
}
@@ -212,6 +221,19 @@ func carryBranchDiff(update *types.GitStatusUpdate, prior types.GitStatusUpdate)
// last known diff data visible until the next successful poll. HEAD is
// required to be unchanged so we don't show stale diffs after a reset/commit.
func carryForwardFileDiffs(ctx context.Context, update *types.GitStatusUpdate, prior types.GitStatusUpdate) error {
budget, err := newDiffBudget(ctx, update)
if err != nil {
return err
}
return carryForwardFileDiffsBudget(ctx, update, prior, &budget)
}
func carryForwardFileDiffsBudget(
ctx context.Context,
update *types.GitStatusUpdate,
prior types.GitStatusUpdate,
budget *diffBudget,
) error {
if prior.HeadCommit == "" || prior.HeadCommit != update.HeadCommit {
return nil
}
@@ -219,37 +241,79 @@ func carryForwardFileDiffs(ctx context.Context, update *types.GitStatusUpdate, p
if err := ctx.Err(); err != nil {
return err
}
// A non-empty Diff means an earlier enrichment pass already populated
// this entry (e.g. unstaged numstat succeeded, then staged numstat
// failed). Leave the freshly-computed entry alone — only fill in
// completely-missing diff data.
if fi.Diff != "" {
continue
}
// A skip reason set this poll (budget_exceeded, too_large, binary)
// is an intentional decision to not surface diff content; restoring
// a prior diff would defeat that skip and could show stale content
// for a file that has since grown past the size or budget cap.
if fi.DiffSkipReason != "" {
continue
}
priorFi, ok := prior.Files[path]
if !ok || priorFi.Diff == "" {
if !ok {
continue
}
fi.Diff = priorFi.Diff
fi.DiffSkipReason = priorFi.DiffSkipReason
if fi.Additions == 0 {
fi.Additions = priorFi.Additions
}
if fi.Deletions == 0 {
fi.Deletions = priorFi.Deletions
// A skip reason set this poll is intentional and must not be replaced by
// cached content. Otherwise fill each missing representation
// independently; a fresh combined diff must not suppress facet carry.
if fi.Diff == "" && fi.DiffSkipReason == "" && priorFi.Diff != "" {
if fi.Additions == 0 {
fi.Additions = priorFi.Additions
}
if fi.Deletions == 0 {
fi.Deletions = priorFi.Deletions
}
if budget.exhausted() {
fi.DiffSkipReason = diffSkipReasonBudgetExceeded
} else {
fi.Diff = priorFi.Diff
fi.DiffSkipReason = priorFi.DiffSkipReason
budget.replace("", fi.Diff)
}
}
fi.StagedChange = carryForwardChangeFacetBudget(fi.StagedChange, priorFi.StagedChange, budget)
fi.UnstagedChange = carryForwardChangeFacetBudget(fi.UnstagedChange, priorFi.UnstagedChange, budget)
update.Files[path] = fi
}
return nil
}
func carryForwardChangeFacetBudget(
current *types.FileChangeFacet,
previous *types.FileChangeFacet,
budget *diffBudget,
) *types.FileChangeFacet {
if current == nil || previous == nil || current.Diff != "" || current.DiffSkipReason != "" || previous.Diff == "" {
return current
}
carried := *current
if carried.Additions == 0 {
carried.Additions = previous.Additions
}
if carried.Deletions == 0 {
carried.Deletions = previous.Deletions
}
if budget.exhausted() {
carried.DiffSkipReason = diffSkipReasonBudgetExceeded
return &carried
}
carried.Diff = previous.Diff
carried.DiffSkipReason = previous.DiffSkipReason
budget.replace("", carried.Diff)
return &carried
}
func carryForwardChangeFacet(
current *types.FileChangeFacet,
previous *types.FileChangeFacet,
) *types.FileChangeFacet {
if current == nil || previous == nil || current.Diff != "" || current.DiffSkipReason != "" || previous.Diff == "" {
return current
}
carried := *current
carried.Diff = previous.Diff
carried.DiffSkipReason = previous.DiffSkipReason
if carried.Additions == 0 {
carried.Additions = previous.Additions
}
if carried.Deletions == 0 {
carried.Deletions = previous.Deletions
}
return &carried
}
// carryForwardFileDiff fills fi.Diff/DiffSkipReason from prior for a single
// file when HEAD matches and prior had diff content. Used when numstat
// succeeded (so Additions/Deletions are fresh) but the per-file capDiffOutput
@@ -424,6 +488,97 @@ func (wt *WorkspaceTracker) enrichUnstagedFileDiff(ctx context.Context, update *
return nil
}
// enrichMixedUnstagedDiffsBudget fills the index-to-working-tree facet for
// paths whose porcelain status contains both layers. The flattened legacy diff
// above remains HEAD-to-working-tree for compatibility.
func (wt *WorkspaceTracker) enrichMixedUnstagedDiffsBudget(
ctx context.Context,
update *types.GitStatusUpdate,
prior types.GitStatusUpdate,
budget *diffBudget,
) error {
if err := ctx.Err(); err != nil {
return err
}
hasMixedFacet := false
for _, fileInfo := range update.Files {
if fileInfo.UnstagedChange != nil {
hasMixedFacet = true
break
}
}
if !hasMixedFacet {
return nil
}
numstatOut, err := wt.runGitOutput(ctx, "diff", "--numstat")
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
wt.logger.Debug("enrichMixedUnstagedDiffs: numstat failed", zap.Error(err))
return nil
}
for _, line := range strings.Split(string(numstatOut), "\n") {
if err := ctx.Err(); err != nil {
return err
}
entry, ok := parseNumstatEntry(line)
if !ok {
continue
}
if err := wt.enrichMixedUnstagedFileDiff(ctx, update, prior, budget, entry); err != nil {
return err
}
}
return ctx.Err()
}
func (wt *WorkspaceTracker) enrichMixedUnstagedFileDiff(
ctx context.Context,
update *types.GitStatusUpdate,
prior types.GitStatusUpdate,
budget *diffBudget,
entry numstatEntry,
) error {
fileInfo, exists := update.Files[entry.path]
if !exists || fileInfo.UnstagedChange == nil {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
change := *fileInfo.UnstagedChange
change.Additions = entry.additions
change.Deletions = entry.deletions
if budget.exhausted() {
change.DiffSkipReason = diffSkipReasonBudgetExceeded
fileInfo.UnstagedChange = &change
update.Files[entry.path] = fileInfo
return nil
}
previousDiff := change.Diff
diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", "--", entry.path)
if err := ctx.Err(); err != nil {
return err
}
if diffOut != "" {
change.Diff = diffOut
if truncated {
change.DiffSkipReason = diffSkipReasonTruncated
}
} else if prior.HeadCommit != "" && prior.HeadCommit == update.HeadCommit {
if priorFile, ok := prior.Files[entry.path]; ok {
carried := carryForwardChangeFacet(&change, priorFile.UnstagedChange)
change = *carried
}
}
budget.replace(previousDiff, change.Diff)
fileInfo.UnstagedChange = &change
update.Files[entry.path] = fileInfo
return nil
}
// enrichWithStagedDiff populates additions/deletions and diff content for staged files
// that have no additional unstaged changes, using git diff --cached. On --cached
// numstat failure the function returns early; carry-forward happens once at the
@@ -483,6 +638,9 @@ func (wt *WorkspaceTracker) enrichStagedFileDiff(ctx context.Context, update *ty
if err := ctx.Err(); err != nil {
return err
}
if fileInfo.StagedChange != nil {
return wt.enrichMixedStagedFileDiff(ctx, update, baseRef, prior, budget, entry, fileInfo)
}
if fileInfo.Additions == 0 && fileInfo.Deletions == 0 {
fileInfo.Additions = entry.additions
fileInfo.Deletions = entry.deletions
@@ -514,6 +672,62 @@ func (wt *WorkspaceTracker) enrichStagedFileDiff(ctx context.Context, update *ty
return nil
}
func (wt *WorkspaceTracker) enrichMixedStagedFileDiff(
ctx context.Context,
update *types.GitStatusUpdate,
baseRef string,
prior types.GitStatusUpdate,
budget *diffBudget,
entry numstatEntry,
fileInfo types.FileInfo,
) error {
change := *fileInfo.StagedChange
change.Additions = entry.additions
change.Deletions = entry.deletions
if budget.exhausted() {
change.DiffSkipReason = diffSkipReasonBudgetExceeded
fileInfo.StagedChange = &change
update.Files[entry.path] = fileInfo
return nil
}
previousDiff := change.Diff
diffOut, truncated := capDiffOutput(ctx, wt.workDir, "diff", "--cached", baseRef, "--", entry.path)
if err := ctx.Err(); err != nil {
return err
}
if diffOut != "" {
change.Diff = diffOut
}
if diffOut != "" && truncated {
change.DiffSkipReason = diffSkipReasonTruncated
}
if diffOut == "" {
change = carryPriorStagedFacet(change, entry.path, update, prior)
}
budget.replace(previousDiff, change.Diff)
fileInfo.StagedChange = &change
update.Files[entry.path] = fileInfo
return nil
}
func carryPriorStagedFacet(
current types.FileChangeFacet,
path string,
update *types.GitStatusUpdate,
prior types.GitStatusUpdate,
) types.FileChangeFacet {
if prior.HeadCommit == "" || prior.HeadCommit != update.HeadCommit {
return current
}
priorFile, ok := prior.Files[path]
if !ok {
return current
}
return *carryForwardChangeFacet(&current, priorFile.StagedChange)
}
// isBinaryContent checks for null bytes in the data, same heuristic git uses.
func isBinaryContent(data []byte) bool {
return bytes.IndexByte(data, 0) != -1
@@ -0,0 +1,270 @@
package process
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/kandev/kandev/internal/agentctl/types"
)
func TestEnrichMixedUnstagedDiffsSkipsGitWithoutMixedFacets(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
tracePath := filepath.Join(t.TempDir(), "git.trace")
t.Setenv("GIT_TRACE", tracePath)
update := &types.GitStatusUpdate{Files: map[string]types.FileInfo{
"plain.txt": {Path: "plain.txt", Status: fileStatusModified},
}}
budget := diffBudget{}
if err := tracker.enrichMixedUnstagedDiffsBudget(
context.Background(), update, types.GitStatusUpdate{}, &budget,
); err != nil {
t.Fatalf("enrichMixedUnstagedDiffsBudget() error = %v", err)
}
if _, err := os.Stat(tracePath); !os.IsNotExist(err) {
t.Fatalf("git trace exists after non-mixed update: %v", err)
}
}
type mixedChangeFacetWire struct {
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Diff string `json:"diff"`
}
// TestWorkspaceGitStatusPreservesMixedChangeFacets covers
// AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9.
func TestWorkspaceGitStatusPreservesMixedChangeFacets(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
const filePath = "mixed-change.txt"
writeFile(t, repoDir, filePath, "base\n")
runGit(t, repoDir, "add", filePath)
runGit(t, repoDir, "commit", "-m", "Add mixed change fixture")
writeFile(t, repoDir, filePath, "base\nSTAGED_LAYER_MARKER\n")
runGit(t, repoDir, "add", filePath)
writeFile(t, repoDir, filePath, "base\nSTAGED_LAYER_MARKER\nUNSTAGED_LAYER_MARKER\n")
tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
status, err := tracker.getGitStatus(context.Background())
if err != nil {
t.Fatalf("getGitStatus() error = %v", err)
}
file, ok := status.Files[filePath]
if !ok {
t.Fatalf("status.Files missing %q", filePath)
}
wire, err := json.Marshal(file)
if err != nil {
t.Fatalf("marshal FileInfo: %v", err)
}
var encoded map[string]json.RawMessage
if err := json.Unmarshal(wire, &encoded); err != nil {
t.Fatalf("unmarshal FileInfo wire shape: %v", err)
}
staged := decodeMixedChangeFacet(t, encoded, "staged_change")
unstaged := decodeMixedChangeFacet(t, encoded, "unstaged_change")
if staged.Status != fileStatusModified || staged.Additions != 1 || staged.Deletions != 0 {
t.Errorf("staged facet = %+v, want modified +1 -0", staged)
}
if !strings.Contains(staged.Diff, "STAGED_LAYER_MARKER") {
t.Errorf("staged diff missing staged marker: %q", staged.Diff)
}
if strings.Contains(staged.Diff, "UNSTAGED_LAYER_MARKER") {
t.Errorf("staged diff contains unstaged marker: %q", staged.Diff)
}
if unstaged.Status != fileStatusModified || unstaged.Additions != 1 || unstaged.Deletions != 0 {
t.Errorf("unstaged facet = %+v, want modified +1 -0", unstaged)
}
if !strings.Contains(unstaged.Diff, "UNSTAGED_LAYER_MARKER") {
t.Errorf("unstaged diff missing unstaged marker: %q", unstaged.Diff)
}
}
// TestWorkspaceGitStatusPreservesAddedMixedChangeFacets covers
// AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9 for an AM path.
func TestWorkspaceGitStatusPreservesAddedMixedChangeFacets(t *testing.T) {
repoDir, cleanup := setupTestRepo(t)
defer cleanup()
const filePath = "mixed-added.txt"
writeFile(t, repoDir, filePath, "STAGED_ADDED_MARKER\n")
runGit(t, repoDir, "add", filePath)
writeFile(t, repoDir, filePath, "STAGED_ADDED_MARKER\nUNSTAGED_ADDED_MARKER\n")
tracker := NewWorkspaceTracker(repoDir, newTestLogger(t))
status, err := tracker.getGitStatus(context.Background())
if err != nil {
t.Fatalf("getGitStatus() error = %v", err)
}
file, ok := status.Files[filePath]
if !ok {
t.Fatalf("status.Files missing %q", filePath)
}
wire, err := json.Marshal(file)
if err != nil {
t.Fatalf("marshal FileInfo: %v", err)
}
var encoded map[string]json.RawMessage
if err := json.Unmarshal(wire, &encoded); err != nil {
t.Fatalf("unmarshal FileInfo wire shape: %v", err)
}
staged := decodeMixedChangeFacet(t, encoded, "staged_change")
unstaged := decodeMixedChangeFacet(t, encoded, "unstaged_change")
if staged.Status != "added" || staged.Additions != 1 || staged.Deletions != 0 {
t.Errorf("staged facet = %+v, want added +1 -0", staged)
}
if strings.Contains(staged.Diff, "UNSTAGED_ADDED_MARKER") {
t.Errorf("staged diff contains unstaged marker: %q", staged.Diff)
}
if unstaged.Status != fileStatusModified || unstaged.Additions != 1 || unstaged.Deletions != 0 {
t.Errorf("unstaged facet = %+v, want modified +1 -0", unstaged)
}
}
// TestMixedChangeFacetDiffBudgetCountsAllRepresentations covers
// AC-PLATFORM-WORKSPACE-GIT-STATUS-001.7.
func TestMixedChangeFacetDiffBudgetCountsAllRepresentations(t *testing.T) {
file := fileInfoFromWire(t, `{
"path":"mixed.txt",
"status":"modified",
"staged":false,
"diff":"base",
"staged_change":{"status":"modified","diff":"staged"},
"unstaged_change":{"status":"modified","diff":"unstaged"}
}`)
update := &types.GitStatusUpdate{Files: map[string]types.FileInfo{"mixed.txt": file}}
total, err := totalDiffBytes(context.Background(), update)
if err != nil {
t.Fatalf("totalDiffBytes() error = %v", err)
}
if total != int64(len("base")+len("staged")+len("unstaged")) {
t.Fatalf("totalDiffBytes() = %d, want all three representations", total)
}
}
// TestCarryForwardMixedChangeFacets covers AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9.
func TestCarryForwardMixedChangeFacets(t *testing.T) {
current := fileInfoFromWire(t, `{
"path":"mixed.txt",
"status":"modified",
"staged":false,
"diff":"fresh combined",
"staged_change":{"status":"modified"},
"unstaged_change":{"status":"modified"}
}`)
priorFile := fileInfoFromWire(t, `{
"path":"mixed.txt",
"status":"modified",
"staged":false,
"diff":"old combined",
"staged_change":{"status":"modified","additions":1,"diff":"cached staged"},
"unstaged_change":{"status":"modified","additions":1,"diff":"cached unstaged"}
}`)
update := &types.GitStatusUpdate{
HeadCommit: "same-head",
Files: map[string]types.FileInfo{"mixed.txt": current},
}
prior := types.GitStatusUpdate{
HeadCommit: "same-head",
Files: map[string]types.FileInfo{"mixed.txt": priorFile},
}
if err := carryForwardFileDiffs(context.Background(), update, prior); err != nil {
t.Fatalf("carryForwardFileDiffs() error = %v", err)
}
wire, err := json.Marshal(update.Files["mixed.txt"])
if err != nil {
t.Fatalf("marshal carried FileInfo: %v", err)
}
var encoded map[string]json.RawMessage
if err := json.Unmarshal(wire, &encoded); err != nil {
t.Fatalf("unmarshal carried FileInfo: %v", err)
}
if got := decodeMixedChangeFacet(t, encoded, "staged_change").Diff; got != "cached staged" {
t.Errorf("staged carried diff = %q, want cached staged", got)
}
if got := decodeMixedChangeFacet(t, encoded, "unstaged_change").Diff; got != "cached unstaged" {
t.Errorf("unstaged carried diff = %q, want cached unstaged", got)
}
}
func TestCarryForwardMixedChangeFacetsRespectsBudget(t *testing.T) {
update := &types.GitStatusUpdate{
HeadCommit: "same-head",
Files: map[string]types.FileInfo{
"mixed.txt": {
Path: "mixed.txt",
Status: fileStatusModified,
Diff: strings.Repeat("x", maxTotalDiffBytes),
StagedChange: &types.FileChangeFacet{Status: fileStatusModified},
UnstagedChange: &types.FileChangeFacet{Status: fileStatusModified},
},
},
}
prior := types.GitStatusUpdate{
HeadCommit: "same-head",
Files: map[string]types.FileInfo{
"mixed.txt": {
Path: "mixed.txt",
Status: fileStatusModified,
StagedChange: &types.FileChangeFacet{Status: fileStatusModified, Diff: "cached staged"},
UnstagedChange: &types.FileChangeFacet{Status: fileStatusModified, Diff: "cached unstaged"},
},
},
}
if err := carryForwardFileDiffs(context.Background(), update, prior); err != nil {
t.Fatalf("carryForwardFileDiffs() error = %v", err)
}
file := update.Files["mixed.txt"]
if file.StagedChange.Diff != "" || file.StagedChange.DiffSkipReason != diffSkipReasonBudgetExceeded {
t.Errorf("staged facet = %+v, want budget-exceeded without cached diff", file.StagedChange)
}
if file.UnstagedChange.Diff != "" || file.UnstagedChange.DiffSkipReason != diffSkipReasonBudgetExceeded {
t.Errorf("unstaged facet = %+v, want budget-exceeded without cached diff", file.UnstagedChange)
}
}
func fileInfoFromWire(t *testing.T, wire string) types.FileInfo {
t.Helper()
var file types.FileInfo
if err := json.Unmarshal([]byte(wire), &file); err != nil {
t.Fatalf("unmarshal FileInfo fixture: %v", err)
}
return file
}
func decodeMixedChangeFacet(
t *testing.T,
encoded map[string]json.RawMessage,
name string,
) mixedChangeFacetWire {
t.Helper()
raw, ok := encoded[name]
if !ok {
full, _ := json.Marshal(encoded)
t.Fatalf("FileInfo missing %s facet: %s", name, full)
}
var facet mixedChangeFacetWire
if err := json.Unmarshal(raw, &facet); err != nil {
t.Fatalf("unmarshal %s facet: %v", name, err)
}
return facet
}
@@ -847,12 +847,16 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
// For renames the format is "old -> new" (each part may be independently
// quoted), so we must split first and unquote each part separately.
filePath := rawPath
if indexStatus != 'R' {
filePath = unquoteGitPath(rawPath)
filePath := unquoteGitPath(rawPath)
oldPath := ""
if indexStatus == 'R' {
if idx := strings.Index(rawPath, " -> "); idx != -1 {
oldPath = unquoteGitPath(rawPath[:idx])
filePath = unquoteGitPath(rawPath[idx+4:])
}
}
fileInfo := types.FileInfo{Path: filePath}
fileInfo := types.FileInfo{Path: filePath, OldPath: oldPath}
// Determine staged status based on index and worktree status.
// Prioritize worktree changes as they represent the current state.
@@ -889,14 +893,35 @@ func (wt *WorkspaceTracker) applyPorcelainLine(line string, update *types.GitSta
case indexStatus == 'R':
fileInfo.Status = "renamed"
fileInfo.Staged = true
// Renamed files have format "old -> new"; each part may be quoted independently.
if idx := strings.Index(rawPath, " -> "); idx != -1 {
fileInfo.OldPath = unquoteGitPath(rawPath[:idx])
filePath = unquoteGitPath(rawPath[idx+4:])
fileInfo.Path = filePath
}
update.Renamed = append(update.Renamed, filePath)
}
// A path can have both an index and a working-tree change (for example MM
// or AM). Keep the flattened compatibility projection above, but preserve
// both independently for consumers that understand mixed paths.
if stagedChange := porcelainChangeFacet(indexStatus, oldPath); stagedChange != nil {
if unstagedChange := porcelainChangeFacet(workTreeStatus, ""); unstagedChange != nil {
fileInfo.StagedChange = stagedChange
fileInfo.UnstagedChange = unstagedChange
}
}
update.Files[filePath] = fileInfo
}
func porcelainChangeFacet(status byte, oldPath string) *types.FileChangeFacet {
change := &types.FileChangeFacet{OldPath: oldPath}
switch status {
case 'M':
change.Status = fileStatusModified
case 'A':
change.Status = "added"
case 'D':
change.Status = fileStatusDeleted
case 'R':
change.Status = "renamed"
default:
return nil
}
return change
}
@@ -97,6 +97,29 @@ type GitStatusUpdate struct {
BranchDeletions int `json:"branch_deletions,omitempty"`
}
// FileChangeFacet is one layer of a mixed file change. A mixed path can have
// one change between HEAD and the index plus another between the index and the
// working tree.
type FileChangeFacet struct {
// Status indicates the layer status: "modified", "added", "deleted", or "renamed".
Status string `json:"status"`
// Additions is the number of added lines in this layer.
Additions int `json:"additions,omitempty"`
// Deletions is the number of deleted lines in this layer.
Deletions int `json:"deletions,omitempty"`
// OldPath is the original path for a rename in this layer.
OldPath string `json:"old_path,omitempty"`
// Diff contains the unified diff content for this layer.
Diff string `json:"diff,omitempty"`
// DiffSkipReason explains why this layer's diff was omitted or truncated.
DiffSkipReason string `json:"diff_skip_reason,omitempty"`
}
// FileInfo represents detailed information about a file's git status.
type FileInfo struct {
// Path is the file path relative to workspace root.
@@ -124,6 +147,12 @@ type FileInfo struct {
// DiffSkipReason explains why diff content was omitted or truncated.
// Values: "too_large", "binary", "truncated", "budget_exceeded".
DiffSkipReason string `json:"diff_skip_reason,omitempty"`
// StagedChange and UnstagedChange preserve the two layers when the same
// path has both index and working-tree changes. Single-layer paths keep the
// compact legacy fields above and omit both facets.
StagedChange *FileChangeFacet `json:"staged_change,omitempty"`
UnstagedChange *FileChangeFacet `json:"unstaged_change,omitempty"`
}
// GitCommitNotification is sent when a new commit is detected in the workspace.
@@ -31,6 +31,7 @@ type (
GitCommitNotification = streams.GitCommitNotification
GitResetNotification = streams.GitResetNotification
GitBranchSwitchNotification = streams.GitBranchSwitchNotification
FileChangeFacet = streams.FileChangeFacet
FileInfo = streams.FileInfo
// File stream types
@@ -90,8 +90,8 @@ export const ReviewDiffList = memo(function ReviewDiffList({
// All in-memory state (selectedFile, reviewedFiles, staleFiles, fileRefs)
// is keyed by `reviewFileKey(file)` so two files at the same path in
// different repos (e.g. `kandev/README.md` + `lvc/README.md`) get
// distinct rows. Without this every multi-repo review with same-named
// files would mark them all reviewed when one is checked.
// distinct rows. Layer-qualified mixed changes use the same key helper so
// reviewing one facet does not mark the other facet reviewed.
const selectedIndex = selectedFile
? files.findIndex((f) => reviewFileKey(f) === selectedFile)
: -1;
@@ -163,6 +163,14 @@ describe("reviewFileKey", () => {
const b = reviewFileKey({ path: "README.md", repository_name: "backend" });
expect(a).not.toBe(b);
});
it("keeps mixed layers distinct without colliding with repository keys", () => {
const staged = reviewFileKey({ path: "mixed.ts", change_layer: "staged" });
const repositoryFile = reviewFileKey({ path: "staged", repository_name: "mixed.ts" });
expect(staged).toBe(`mixed.ts${SEP}${SEP}staged`);
expect(staged).not.toBe(repositoryFile);
});
});
describe("resolvePRReviewRepositoryName", () => {
@@ -240,6 +248,20 @@ describe("splitReviewFileKey", () => {
});
});
it("round-trips a layer-qualified repository key", () => {
const key = reviewFileKey({
path: APP_PATH,
repository_name: "frontend",
change_layer: "unstaged",
});
expect(splitReviewFileKey(key)).toEqual({
repositoryName: "frontend",
path: APP_PATH,
changeLayer: "unstaged",
});
});
it("keeps a legacy bare-path key without a repository scope", () => {
expect(splitReviewFileKey(APP_PATH)).toEqual({ repositoryName: "", path: APP_PATH });
});
+42 -8
View File
@@ -1,6 +1,16 @@
import { djb2Hash } from "@/lib/utils/hash";
import { t } from "@/lib/i18n";
import type { FileChangeStatus } from "@/lib/utils/file-change-status";
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
export type ReviewChangeFacet = {
diff: string;
status: FileChangeStatus;
additions: number;
deletions: number;
old_path?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type ReviewFile = {
path: string;
@@ -12,6 +22,10 @@ export type ReviewFile = {
source: "uncommitted" | "committed" | "pr";
old_path?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
staged_change?: ReviewChangeFacet;
unstaged_change?: ReviewChangeFacet;
/** Frontend-only layer selected from a mixed uncommitted file. */
change_layer?: GitChangeLayer;
/**
* Repository this file belongs to. Set on multi-repo task changes so the
* file tree groups files under per-repo top-level nodes. Optional for
@@ -35,13 +49,22 @@ export type ReviewFile = {
* keep the legacy bare-path key for backwards compatibility with existing
* `session_file_reviews` rows. An explicit empty name represents the real
* workspace root in a multi-repo payload and therefore keeps the separator.
* Layer-qualified mixed changes append a double-NUL marker plus the layer;
* the doubled marker cannot collide with a legacy repository/path key.
*/
const FILE_KEY_SEP = "\u0000";
const FILE_LAYER_KEY_SEP = `${FILE_KEY_SEP}${FILE_KEY_SEP}`;
export function reviewFileKey(file: { path: string; repository_name?: string }): string {
return file.repository_name === undefined
? file.path
: `${file.repository_name}${FILE_KEY_SEP}${file.path}`;
export function reviewFileKey(file: {
path: string;
repository_name?: string;
change_layer?: GitChangeLayer;
}): string {
const baseKey =
file.repository_name === undefined
? file.path
: `${file.repository_name}${FILE_KEY_SEP}${file.path}`;
return file.change_layer ? `${baseKey}${FILE_LAYER_KEY_SEP}${file.change_layer}` : baseKey;
}
/** Mirrors backend `worktree.SanitizeRepoDirName`, which defines the
@@ -124,10 +147,21 @@ export function getCumulativeReviewRepositoryNames(
return Array.from(names);
}
export function splitReviewFileKey(key: string): { repositoryName: string; path: string } {
const sep = key.indexOf(FILE_KEY_SEP);
if (sep < 0) return { repositoryName: "", path: key };
return { repositoryName: key.slice(0, sep), path: key.slice(sep + 1) };
export function splitReviewFileKey(key: string): {
repositoryName: string;
path: string;
changeLayer?: GitChangeLayer;
} {
const layerSep = key.lastIndexOf(FILE_LAYER_KEY_SEP);
const layer = layerSep < 0 ? undefined : key.slice(layerSep + FILE_LAYER_KEY_SEP.length);
const changeLayer = layer === "staged" || layer === "unstaged" ? layer : undefined;
const baseKey = changeLayer ? key.slice(0, layerSep) : key;
const sep = baseKey.indexOf(FILE_KEY_SEP);
const identity =
sep < 0
? { repositoryName: "", path: baseKey }
: { repositoryName: baseKey.slice(0, sep), path: baseKey.slice(sep + 1) };
return changeLayer ? { ...identity, changeLayer } : identity;
}
export function diffSkipReasonLabel(reason?: string): string {
@@ -1,4 +1,7 @@
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
export type DiffSource = "uncommitted" | "committed" | "pr";
export type ChangeLayer = GitChangeLayer;
export type LocalCommitDetailTarget = {
source: "local";
@@ -23,6 +26,7 @@ export type OpenDiffOptions = {
source?: DiffSource;
repositoryName?: string;
prKey?: string;
changeLayer?: ChangeLayer;
};
export type DiffSheetMode =
@@ -33,5 +37,6 @@ export type DiffSheetMode =
sourceFilter?: "all" | DiffSource;
repositoryName?: string;
prKey?: string;
changeLayer?: ChangeLayer;
}
| { kind: "commit"; target: CommitDetailTarget };
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { reviewFileKey } from "@/components/review/types";
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
import { useTranslation } from "react-i18next";
/** Composite key for the file currently selected in single-file mode. */
@@ -8,13 +9,18 @@ export function useSelectedFileKey(
mode: "all" | "file",
filePath: string | undefined,
fileRepositoryName: string | undefined,
changeLayer?: GitChangeLayer,
): string | undefined {
return useMemo(
() =>
mode === "file" && filePath
? reviewFileKey({ path: filePath, repository_name: fileRepositoryName })
? reviewFileKey({
path: filePath,
repository_name: fileRepositoryName,
change_layer: changeLayer,
})
: undefined,
[mode, filePath, fileRepositoryName],
[mode, filePath, fileRepositoryName, changeLayer],
);
}
@@ -96,7 +96,12 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
<TooltipProvider>
<ul>
<FileRow
file={{ ...baseFile, path: "README.md", repositoryName: "frontend" }}
file={{
...baseFile,
path: "README.md",
repositoryName: "frontend",
changeLayer: "staged",
}}
isPending={false}
onSelect={noopSelect}
onOpenDiff={onOpenDiff}
@@ -115,6 +120,7 @@ describe("FileRow truncation (regression: path overlaps diff stats in narrow pan
expect(onOpenDiff).toHaveBeenCalledWith("README.md", {
source: "uncommitted",
repositoryName: "frontend",
changeLayer: "staged",
});
});
@@ -306,6 +312,36 @@ describe("FileRow tree-mode hover stage action", () => {
expect(rightActions).not.toBeNull();
expect(rightActions!.querySelector("button[title='Unstage file']")).toBeNull();
});
it("keeps the tree-mode stage action visible and touch-sized for coarse pointers", () => {
responsive.isFinePointer = false;
const { container } = render(
<TooltipProvider>
<ul>
<FileRow
file={{ ...baseFile, path: "src/mobile.go", staged: true }}
isPending={false}
treeMode
onSelect={noopSelect}
onOpenDiff={noop}
onStage={noop}
onUnstage={noop}
onDiscard={noop}
onEditFile={noop}
/>
</ul>
</TooltipProvider>,
);
const iconSlot = container.querySelector("[data-testid='file-row-icon-action-slot']");
const action = iconSlot?.querySelector("button[title='Unstage file']");
expect(iconSlot?.className).toContain("size-11");
expect(action?.className).toContain("min-h-11");
expect(action?.className).toContain("min-w-11");
expect(action?.parentElement?.className).not.toContain("opacity-0");
});
});
describe("FileRow active-tab highlight", () => {
@@ -21,6 +21,8 @@ import type { OpenDiffOptions } from "./changes-diff-target";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const COARSE_POINTER_TARGET_CLASS = "min-h-11 min-w-11";
const splitPath = (path: string) => {
const lastSlash = path.lastIndexOf("/");
if (lastSlash === -1) return { folder: "", file: path };
@@ -85,6 +87,7 @@ export function FileRow({
onOpenDiff(file.path, {
source: "uncommitted",
repositoryName: file.repositoryName,
...(file.changeLayer ? { changeLayer: file.changeLayer } : {}),
});
}
};
@@ -112,6 +115,7 @@ export function FileRow({
<TreeModeFileActionSlot
name={name}
isPending={isPending}
isFinePointer={isFinePointer}
staged={file.staged}
path={file.path}
repo={file.repositoryName}
@@ -122,6 +126,7 @@ export function FileRow({
<StageButton
isPending={isPending}
staged={file.staged}
touchSized={!isFinePointer}
path={file.path}
repo={file.repositoryName}
onStage={onStage}
@@ -170,6 +175,7 @@ function FileRowStats({ file, isFinePointer }: { file: ChangedFile; isFinePointe
function TreeModeFileActionSlot({
name,
isPending,
isFinePointer,
staged,
path,
repo,
@@ -178,6 +184,7 @@ function TreeModeFileActionSlot({
}: {
name: string;
isPending: boolean;
isFinePointer: boolean;
staged: boolean;
path: string;
repo?: string;
@@ -187,16 +194,27 @@ function TreeModeFileActionSlot({
return (
<div
data-testid="file-row-icon-action-slot"
className="grid size-4 shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1"
className={cn(
"grid shrink-0 items-center justify-center [&>*]:col-start-1 [&>*]:row-start-1",
isFinePointer ? "size-4" : "size-11",
)}
>
<FileIcon
fileName={name}
className="size-4 transition-opacity group-hover:opacity-0 pointer-events-none"
/>
<div className="opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isFinePointer && (
<FileIcon
fileName={name}
className="size-4 transition-opacity group-hover:opacity-0 pointer-events-none"
/>
)}
<div
className={cn(
isFinePointer &&
"opacity-0 transition-opacity pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto",
)}
>
<StageButton
isPending={isPending}
staged={staged}
touchSized={!isFinePointer}
path={path}
repo={repo}
onStage={onStage}
@@ -210,6 +228,7 @@ function TreeModeFileActionSlot({
function StageButton({
isPending,
staged,
touchSized,
path,
repo,
onStage,
@@ -217,6 +236,7 @@ function StageButton({
}: {
isPending: boolean;
staged: boolean;
touchSized?: boolean;
path: string;
repo?: string;
onStage: (path: string, repo?: string) => void;
@@ -225,7 +245,12 @@ function StageButton({
const { t } = useTranslation();
if (isPending) {
return (
<div className="flex-shrink-0 flex items-center justify-center size-4">
<div
className={cn(
"flex-shrink-0 flex items-center justify-center size-4",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
>
<IconLoader2 className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
);
@@ -235,7 +260,10 @@ function StageButton({
<button
type="button"
title={t("task:unstageFile")}
className="group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer"
className={cn(
"group/unstage flex-shrink-0 flex items-center justify-center size-4 rounded bg-emerald-500/20 text-emerald-600 hover:bg-rose-500/20 hover:text-rose-600 cursor-pointer",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
onClick={(e) => {
e.stopPropagation();
onUnstage(path, repo);
@@ -250,7 +278,10 @@ function StageButton({
<button
type="button"
title={t("task:stageFile")}
className="flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer"
className={cn(
"flex-shrink-0 flex items-center justify-center size-4 rounded border border-dashed border-muted-foreground/50 text-muted-foreground hover:border-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer",
touchSized && COARSE_POINTER_TARGET_CLASS,
)}
onClick={(e) => {
e.stopPropagation();
onStage(path, repo);
@@ -292,7 +323,7 @@ function FileRowActions({
aria-label={t("task:discardChanges2")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && "min-h-11 min-w-11",
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
)}
onClick={(e) => {
e.stopPropagation();
@@ -311,7 +342,7 @@ function FileRowActions({
aria-label={t("common:edit")}
className={cn(
"text-muted-foreground hover:text-foreground cursor-pointer",
!isFinePointer && "min-h-11 min-w-11",
!isFinePointer && COARSE_POINTER_TARGET_CLASS,
)}
onClick={(e) => {
e.stopPropagation();
@@ -6,7 +6,11 @@ import { useShallow } from "zustand/react/shallow";
import { useAppStore } from "@/components/state-provider";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { AppState } from "@/lib/state/store";
import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import type {
FileChangeFacet,
FileInfo,
GitStatusEntry,
} from "@/lib/state/slices/session-runtime/types";
import { djb2Hash } from "@/lib/utils/hash";
type DockviewPanel = NonNullable<ReturnType<DockviewApi["getPanel"]>>;
@@ -54,6 +58,18 @@ export function autoActivateChangesPanel(): ActivateChangesPanelResult {
const fileFingerprintCache = new WeakMap<FileInfo, string>();
const gitStatusFingerprintCache = new WeakMap<GitStatusEntry, GitStatusFingerprintCacheEntry>();
function changeFacetFingerprint(facet: FileChangeFacet | undefined): string {
if (!facet) return "";
return [
facet.status,
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
djb2Hash(facet.diff ?? ""),
facet.diff_skip_reason ?? "",
].join(":");
}
function fileFingerprint(file: FileInfo): string {
const cached = fileFingerprintCache.get(file);
if (cached !== undefined) return cached;
@@ -67,6 +83,8 @@ function fileFingerprint(file: FileInfo): string {
file.old_path ?? "",
djb2Hash(file.diff ?? ""),
file.diff_skip_reason ?? "",
changeFacetFingerprint(file.staged_change),
changeFacetFingerprint(file.unstaged_change),
file.repository_name ?? "",
].join(":");
fileFingerprintCache.set(file, fingerprint);
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
buildPrByRepoMap,
computeReviewProgress,
mapToChangedFiles,
mapPRFilesToChangedFiles,
selectPRFilesForReviewProgress,
type ReviewProgressPRFile,
@@ -12,6 +13,27 @@ import type { PRDiffFile } from "@/lib/types/github";
const PRIMARY_PR_ID = "primary-pr";
const RENAMED_PATH = "src/renamed.ts";
describe("mapToChangedFiles", () => {
it("preserves a projected mixed-change layer", () => {
expect(
mapToChangedFiles([
{
path: "src/mixed.ts",
status: "modified",
staged: true,
change_layer: "staged",
},
]),
).toEqual([
expect.objectContaining({
path: "src/mixed.ts",
staged: true,
changeLayer: "staged",
}),
]);
});
});
function diffFile(overrides: Partial<PRDiffFile>): PRDiffFile {
return {
filename: "src/app.ts",
@@ -10,7 +10,7 @@ import type { FileInfo } from "@/lib/state/store";
import type { PRDiffFile } from "@/lib/types/github";
import { normalizeFileChangeStatus, type FileChangeStatus } from "@/lib/utils/file-change-status";
import type { PRChangedFile } from "./changes-panel-timeline";
import type { CommitDetailTarget } from "./changes-diff-target";
import type { ChangeLayer, CommitDetailTarget } from "./changes-diff-target";
import type { CommitPresentation } from "./commit-row";
export type ChangedFile = {
@@ -22,6 +22,7 @@ export type ChangedFile = {
oldPath: string | undefined;
/** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */
repositoryName?: string;
changeLayer?: ChangeLayer;
};
/**
@@ -62,6 +63,7 @@ export function mapToChangedFiles(files: FileInfo[]): ChangedFile[] {
minus: file.deletions,
oldPath: file.old_path,
repositoryName: file.repository_name,
changeLayer: file.change_layer,
}));
}
@@ -118,6 +118,8 @@ function DiffViewerContent({
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
const selectedPRKey = panelKind === "file" ? (params?.prKey as string | undefined) : undefined;
const selectedChangeLayer =
panelKind === "file" ? (params?.changeLayer as OpenDiffOptions["changeLayer"]) : undefined;
const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
const handleClosePanel = useCallback(() => {
@@ -132,6 +134,7 @@ function DiffViewerContent({
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
prKey={selectedPRKey}
changeLayer={selectedChangeLayer}
sourceFilter={sourceFilter}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
@@ -171,6 +174,7 @@ function ChangesContent({ panelId }: { panelId: string }) {
source: options?.source,
repositoryName: options?.repositoryName,
prKey: options?.prKey,
changeLayer: options?.changeLayer,
}),
[addFileDiffPanel],
);
@@ -294,6 +294,8 @@ function DiffViewerContent({
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
const selectedPRKey = panelKind === "file" ? (params?.prKey as string | undefined) : undefined;
const selectedChangeLayer =
panelKind === "file" ? (params?.changeLayer as OpenDiffOptions["changeLayer"]) : undefined;
const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
useResyncGitStatusOnTabActivate(panelId, activeSessionId);
@@ -309,6 +311,7 @@ function DiffViewerContent({
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
prKey={selectedPRKey}
changeLayer={selectedChangeLayer}
sourceFilter={sourceFilter}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
@@ -352,6 +355,7 @@ function ChangesContent({ panelId }: { panelId: string }) {
source: options?.source,
repositoryName: options?.repositoryName,
prKey: options?.prKey,
changeLayer: options?.changeLayer,
}),
[addFileDiffPanel],
);
@@ -86,6 +86,7 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({
sourceFilter: options?.source ?? "all",
repositoryName: options?.repositoryName || undefined,
prKey: options?.prKey,
changeLayer: options?.changeLayer,
});
}, []);
@@ -178,6 +178,7 @@ function renderPanel(
const filePath = mode.kind === "file" ? mode.path : undefined;
const fileRepositoryName = mode.kind === "file" ? mode.repositoryName : undefined;
const prKey = mode.kind === "file" ? mode.prKey : undefined;
const changeLayer = mode.kind === "file" ? mode.changeLayer : undefined;
const effectiveSourceFilter = mode.kind === "all" ? activeSource : (mode.sourceFilter ?? "all");
return (
<TaskChangesPanel
@@ -185,6 +186,7 @@ function renderPanel(
filePath={filePath}
fileRepositoryName={fileRepositoryName}
prKey={prKey}
changeLayer={changeLayer}
selectedDiff={selectedDiff}
onClearSelected={onClearSelected}
onOpenFile={onOpenFile}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { hashDiff, reviewFileKey, type ReviewFile } from "@/components/review/types";
import { useSelectedFileKey } from "./changes-panel-banner";
import {
computeChangesReviewSets,
filterVisibleFiles,
reviewDiffHashForKey,
useVisibleDiffState,
} from "./task-changes-panel-state";
const MIXED_PATH = "mixed.ts";
const STAGED_DIFF = "staged diff";
const UNSTAGED_DIFF = "unstaged diff";
function mixedFile(): ReviewFile {
return {
path: MIXED_PATH,
source: "uncommitted",
status: "modified",
staged: false,
additions: 2,
deletions: 0,
diff: "combined",
staged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: STAGED_DIFF,
},
unstaged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: UNSTAGED_DIFF,
},
};
}
function projectLayer(file: ReviewFile, changeLayer: "staged" | "unstaged"): ReviewFile {
const [projected] = filterVisibleFiles([file], {
mode: "file",
filePath: MIXED_PATH,
fileRepositoryName: undefined,
sourceFilter: "uncommitted",
changeLayer,
});
return projected;
}
describe("filterVisibleFiles mixed layers", () => {
it("projects the requested layer for a mixed uncommitted file", () => {
expect(projectLayer(mixedFile(), "staged")).toEqual(
expect.objectContaining({
diff: STAGED_DIFF,
staged: true,
change_layer: "staged",
additions: 1,
}),
);
});
it("keeps staged and unstaged review identities independent", () => {
const mixed = mixedFile();
const staged = projectLayer(mixed, "staged");
const unstaged = projectLayer(mixed, "unstaged");
expect(reviewFileKey(staged)).not.toBe(reviewFileKey(unstaged));
});
it("selects the layer-qualified review row", () => {
const { result } = renderHook(() =>
useSelectedFileKey("file", MIXED_PATH, undefined, "staged"),
);
expect(result.current).toBe(reviewFileKey({ path: MIXED_PATH, change_layer: "staged" }));
});
});
describe("mixed layer review state", () => {
it("restores review state against the selected layer diff", () => {
const mixed = mixedFile();
const staged = projectLayer(mixed, "staged");
const stagedKey = reviewFileKey(staged);
const state = computeChangesReviewSets(
[mixed],
new Map([[stagedKey, { reviewed: true, diffHash: hashDiff(staged.diff) }]]),
);
expect(state.reviewedFiles).toEqual(new Set([stagedKey]));
expect(state.staleFiles).toEqual(new Set());
});
it("persists the selected layer diff hash", () => {
const mixed = mixedFile();
const staged = projectLayer(mixed, "staged");
expect(reviewDiffHashForKey([mixed], reviewFileKey(staged))).toBe(hashDiff(staged.diff));
});
it("keys the selected layer ref by its review identity", () => {
const mixed = mixedFile();
const ref = { current: null };
const fileRefs = new Map([[reviewFileKey(mixed), ref]]);
const { result } = renderHook(() =>
useVisibleDiffState({
allFiles: [mixed],
rawPRFiles: [],
mode: "file",
filePath: MIXED_PATH,
fileRepositoryName: undefined,
sourceFilter: "uncommitted",
prKey: undefined,
changeLayer: "staged",
fileRefs,
reviewedFiles: new Set(),
staleFiles: new Set(),
}),
);
const [visible] = result.current.visibleFiles;
expect(result.current.visibleFileRefs.get(reviewFileKey(visible))).toBe(ref);
});
});
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useMemo, useRef, type MutableRefObject, type RefObject } from "react";
import { reviewFileKey, splitReviewFileKey, type ReviewFile } from "@/components/review/types";
import { hashDiff, reviewFileKey, type ReviewFile } from "@/components/review/types";
import type { ReviewSource } from "@/hooks/domains/session/use-review-sources";
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
export function shouldDeferReviewStateForPR(
hasSelectedPR: boolean,
@@ -104,13 +105,84 @@ type FilterVisibleFilesOpts = {
sourceFilter: "all" | ReviewSource;
rawPRFiles?: ReviewFile[];
prKey?: string;
changeLayer?: GitChangeLayer;
};
function projectReviewFileLayer(file: ReviewFile, layer: GitChangeLayer): ReviewFile | null {
if (file.source !== "uncommitted") return file;
const facet = layer === "staged" ? file.staged_change : file.unstaged_change;
if (!facet) {
if (file.staged !== (layer === "staged")) return null;
return { ...file, change_layer: layer };
}
return {
...file,
...facet,
staged: layer === "staged",
change_layer: layer,
};
}
type PersistedReviewState = { reviewed: boolean; diffHash: string };
function reviewStateFiles(files: ReviewFile[]): ReviewFile[] {
const stateFiles: ReviewFile[] = [];
for (const file of files) {
stateFiles.push(file);
if (
file.source !== "uncommitted" ||
file.change_layer ||
!file.staged_change ||
!file.unstaged_change
) {
continue;
}
for (const layer of ["staged", "unstaged"] as const) {
const projected = projectReviewFileLayer(file, layer);
if (projected) stateFiles.push(projected);
}
}
return stateFiles;
}
export function computeChangesReviewSets(
files: ReviewFile[],
reviews: ReadonlyMap<string, PersistedReviewState>,
): { reviewedFiles: Set<string>; staleFiles: Set<string> } {
const reviewedFiles = new Set<string>();
const staleFiles = new Set<string>();
for (const file of reviewStateFiles(files)) {
const key = reviewFileKey(file);
const reviewState = reviews.get(key);
if (!reviewState?.reviewed) continue;
const currentHash = hashDiff(file.diff);
if (reviewState.diffHash && reviewState.diffHash !== currentHash) staleFiles.add(key);
else reviewedFiles.add(key);
}
return { reviewedFiles, staleFiles };
}
export function reviewDiffHashForKey(files: ReviewFile[], key: string): string {
const file = reviewStateFiles(files).find((candidate) => reviewFileKey(candidate) === key);
return file ? hashDiff(file.diff) : "";
}
function projectRequestedLayer(
files: ReviewFile[],
mode: FilterVisibleFilesOpts["mode"],
changeLayer: GitChangeLayer | undefined,
): ReviewFile[] {
if (mode !== "file" || !changeLayer) return files;
return files
.map((file) => projectReviewFileLayer(file, changeLayer))
.filter((file): file is ReviewFile => file !== null);
}
export function filterVisibleFiles(
allFiles: ReviewFile[],
opts: FilterVisibleFilesOpts,
): ReviewFile[] {
const { mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles, prKey } = opts;
const { mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles, prKey, changeLayer } = opts;
const repositoryFilter = prKey && sourceFilter === "pr" ? undefined : fileRepositoryName;
if (mode === "file" && filePath && sourceFilter === "pr" && rawPRFiles?.length) {
let prFiles = rawPRFiles.filter((file) => file.path === filePath && file.source === "pr");
@@ -128,7 +200,7 @@ export function filterVisibleFiles(
}
}
if (sourceFilter !== "all") files = files.filter((file) => file.source === sourceFilter);
return files;
return projectRequestedLayer(files, mode, changeLayer);
}
export function useVisibleDiffState(opts: {
@@ -139,6 +211,7 @@ export function useVisibleDiffState(opts: {
fileRepositoryName: string | undefined;
sourceFilter: "all" | ReviewSource;
prKey: string | undefined;
changeLayer: GitChangeLayer | undefined;
fileRefs: Map<string, RefObject<HTMLDivElement | null>>;
reviewedFiles: Set<string>;
staleFiles: Set<string>;
@@ -151,6 +224,7 @@ export function useVisibleDiffState(opts: {
fileRepositoryName,
sourceFilter,
prKey,
changeLayer,
fileRefs,
reviewedFiles,
staleFiles,
@@ -164,23 +238,21 @@ export function useVisibleDiffState(opts: {
sourceFilter,
rawPRFiles,
prKey,
changeLayer,
}),
[allFiles, mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles, prKey],
[allFiles, mode, filePath, fileRepositoryName, sourceFilter, rawPRFiles, prKey, changeLayer],
);
const repositoryFilter = prKey && sourceFilter === "pr" ? undefined : fileRepositoryName;
const visibleFileRefs = useMemo(() => {
if (mode !== "file" || !filePath) return fileRefs;
const refs = new Map<string, RefObject<HTMLDivElement | null>>();
for (const [key, ref] of fileRefs.entries()) {
const split = splitReviewFileKey(key);
if (split.path !== filePath) continue;
if (repositoryFilter !== undefined && (split.repositoryName || "") !== repositoryFilter) {
continue;
}
refs.set(key, ref);
for (const file of visibleFiles) {
const visibleKey = reviewFileKey(file);
const rawKey = reviewFileKey({ path: file.path, repository_name: file.repository_name });
const ref = fileRefs.get(visibleKey) ?? fileRefs.get(rawKey);
if (ref) refs.set(visibleKey, ref);
}
return refs;
}, [mode, filePath, fileRefs, repositoryFilter]);
}, [mode, filePath, fileRefs, visibleFiles]);
const reviewedCount = useMemo(
() =>
visibleFiles.reduce((count, file) => {
+13 -18
View File
@@ -17,12 +17,14 @@ import { ReviewDiffList } from "@/components/review/review-diff-list";
import { ReviewPRDiffBoundary } from "@/components/review/review-dialog-pr-state";
import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults";
import type { ReviewFile } from "@/components/review/types";
import { hashDiff, reviewFileKey, splitReviewFileKey } from "@/components/review/types";
import { reviewFileKey, splitReviewFileKey } from "@/components/review/types";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough";
import { ChangesTopBar } from "./changes-top-bar";
import {
computeChangesReviewSets,
resolveSelectedFileRepositoryName,
reviewDiffHashForKey,
shouldBlockChangesForPR,
shouldDeferReviewStateForPR,
useAutoCloseWhenEmpty,
@@ -31,11 +33,13 @@ import {
import type { SelectedDiff } from "./task-layout";
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
import { useTranslation } from "react-i18next";
import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types";
type TaskChangesPanelProps = {
mode?: "all" | "file";
filePath?: string;
fileRepositoryName?: string;
changeLayer?: GitChangeLayer;
prKey?: string;
selectedDiff: SelectedDiff | null;
onClearSelected: () => void;
@@ -118,18 +122,7 @@ function useChangesView(
if (shouldDeferReviewStateForPR(Boolean(pr), prDiffLoading, sourceFilter)) {
return { reviewedFiles: reviewed, staleFiles: stale };
}
for (const file of allFiles) {
const key = reviewFileKey(file);
const reviewState = reviews.get(key);
if (!reviewState?.reviewed) continue;
const currentHash = hashDiff(file.diff);
if (reviewState.diffHash && reviewState.diffHash !== currentHash) {
stale.add(key);
} else {
reviewed.add(key);
}
}
return { reviewedFiles: reviewed, staleFiles: stale };
return computeChangesReviewSets(allFiles, reviews);
}, [allFiles, reviews, pr, prDiffLoading, sourceFilter]);
const totalCommentCount = useMemo(() => {
@@ -210,6 +203,7 @@ function useChangesPRPresentation(opts: {
mode: "all" | "file";
filePath: string | undefined;
fileRepositoryName: string | undefined;
changeLayer: GitChangeLayer | undefined;
visibleFiles: ReviewFile[];
}) {
const visibleSelectedFile = opts.visibleFiles.find((file) => file.path === opts.filePath);
@@ -222,6 +216,7 @@ function useChangesPRPresentation(opts: {
opts.fileRepositoryName,
visibleSelectedFile?.repository_name,
),
opts.changeLayer,
);
const blockChangesForPR = shouldBlockChangesForPR(opts.sourceFilter, opts.visibleFiles);
return { selectedFileKey, blockChangesForPR };
@@ -258,8 +253,7 @@ function useChangesActions(
const handleToggleReviewed = useCallback(
(key: string, reviewed: boolean) => {
if (reviewed) {
const file = allFiles.find((f) => reviewFileKey(f) === key);
markReviewed(key, file ? hashDiff(file.diff) : "");
markReviewed(key, reviewDiffHashForKey(allFiles, key));
} else {
markUnreviewed(key);
}
@@ -338,6 +332,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
mode = "all",
filePath,
fileRepositoryName,
changeLayer,
prKey,
selectedDiff,
onClearSelected,
@@ -355,14 +350,13 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
const relevantPRLoading = usesPRDiff && view.prDiffLoading;
const actions = useChangesActions(view.activeSessionId, view.allFiles, wordWrapProp);
const handleRequestWalkthrough = useWalkthroughRequest(view.activeSessionId, view.allFiles);
const fileTarget = { filePath, fileRepositoryName, prKey, changeLayer };
const visible = useVisibleDiffState({
allFiles: view.allFiles,
rawPRFiles: view.rawPRFiles,
mode,
filePath,
fileRepositoryName,
...fileTarget,
sourceFilter,
prKey,
fileRefs: view.fileRefs,
reviewedFiles: view.reviewedFiles,
staleFiles: view.staleFiles,
@@ -373,6 +367,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
mode,
filePath,
fileRepositoryName,
changeLayer,
visibleFiles: visible.visibleFiles,
});
useAutoCloseWhenEmpty({
@@ -8,6 +8,7 @@ import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import { waitForDiffText, waitForDiffTextAbsent } from "./diff-update-helpers";
// ---------------------------------------------------------------------------
// Git helper for E2E tests - runs git commands in the test repository
@@ -206,6 +207,71 @@ test.describe("Git Changes Panel", () => {
await expect(session.changes.getByText("test-file.txt")).toBeVisible({ timeout: 15_000 });
});
// @covers AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10
test("shows same path in staged and unstaged sections", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const profile = await createStandardProfile(apiClient, "Git Mixed Change Profile");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Mixed Change Test", profile.id, {
description: "Testing mixed staged and unstaged changes",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, "Git Mixed Change Test");
await session.waitForChatIdle();
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, {
...process.env,
HOME: backend.tmpDir,
GIT_AUTHOR_NAME: "E2E Test",
GIT_AUTHOR_EMAIL: "e2e@test.local",
GIT_COMMITTER_NAME: "E2E Test",
GIT_COMMITTER_EMAIL: "e2e@test.local",
});
git.createFile("mixed-layer.txt", "base\n");
git.stageFile("mixed-layer.txt");
git.commit("Add mixed layer fixture");
git.modifyFile("mixed-layer.txt", "base\nSTAGED_LAYER_MARKER\n");
git.stageFile("mixed-layer.txt");
git.modifyFile("mixed-layer.txt", "base\nSTAGED_LAYER_MARKER\nUNSTAGED_LAYER_MARKER\n");
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
await session.expandChangesSection("unstaged-files-section");
await session.expandChangesSection("staged-files-section");
const unstagedRow = testPage
.getByTestId("unstaged-files-section")
.getByTestId("file-row-mixed-layer.txt");
const stagedRow = testPage
.getByTestId("staged-files-section")
.getByTestId("file-row-mixed-layer.txt");
await expect(unstagedRow).toBeVisible();
await expect(stagedRow).toBeVisible();
await expect(unstagedRow.getByText("+1", { exact: true })).toBeVisible();
await expect(stagedRow.getByText("+1", { exact: true })).toBeVisible();
await unstagedRow.click();
await waitForDiffText(testPage, "UNSTAGED_LAYER_MARKER");
await stagedRow.click();
await waitForDiffText(testPage, "STAGED_LAYER_MARKER");
await waitForDiffTextAbsent(testPage, "UNSTAGED_LAYER_MARKER");
await stagedRow.getByTitle("Unstage file").click();
await expect(stagedRow).toHaveCount(0);
await expect(unstagedRow).toBeVisible();
await expect(unstagedRow.getByText("+2", { exact: true })).toBeVisible();
});
/**
* Verifies that new untracked files appear in the unstaged section.
*/
@@ -42,6 +42,32 @@ async function expectDiffText(testPage: Page, text: string, timeout = 45_000) {
);
}
async function expectDiffTextAbsent(testPage: Page, text: string, timeout = 10_000) {
await expect
.poll(
() =>
testPage.evaluate((searchText: string) => {
const visibleContainers = Array.from(document.querySelectorAll("diffs-container")).filter(
(container) => {
const bounds = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
return (
bounds.width > 0 &&
bounds.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
},
);
return visibleContainers.every(
(container) => !container.shadowRoot?.textContent?.includes(searchText),
);
}, text),
{ timeout },
)
.toBe(true);
}
test.describe("Mobile changes panel", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
@@ -181,6 +207,74 @@ test.describe("Mobile changes panel", () => {
await testPage.getByTestId("mobile-diff-sheet-close").tap();
});
// @covers AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12
test("shows same path in staged and unstaged sections", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Mixed Change",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
git.createFile("mobile-mixed-layer.txt", "base\n");
git.stageFile("mobile-mixed-layer.txt");
git.commit("Add mobile mixed layer fixture");
git.modifyFile("mobile-mixed-layer.txt", "base\nMOBILE_STAGED_LAYER_MARKER\n");
git.stageFile("mobile-mixed-layer.txt");
git.modifyFile(
"mobile-mixed-layer.txt",
"base\nMOBILE_STAGED_LAYER_MARKER\nMOBILE_UNSTAGED_LAYER_MARKER\n",
);
await openMobileChangesPanel(testPage);
await expandSection(testPage, "unstaged-files-section");
await expandSection(testPage, "staged-files-section");
const unstagedRow = testPage
.getByTestId("unstaged-files-section")
.getByTestId("file-row-mobile-mixed-layer.txt");
const stagedRow = testPage
.getByTestId("staged-files-section")
.getByTestId("file-row-mobile-mixed-layer.txt");
await expect(unstagedRow).toBeVisible();
await expect(stagedRow).toBeVisible();
await expect(unstagedRow.getByText("+1", { exact: true })).toBeVisible();
await expect(stagedRow.getByText("+1", { exact: true })).toBeVisible();
await unstagedRow.tap();
await expect(testPage.getByText("File Changes")).toBeVisible({ timeout: 10_000 });
await expectDiffText(testPage, "MOBILE_UNSTAGED_LAYER_MARKER");
await testPage.getByTestId("mobile-diff-sheet-close").tap();
await stagedRow.tap();
await expect(testPage.getByText("File Changes")).toBeVisible({ timeout: 10_000 });
await expectDiffText(testPage, "MOBILE_STAGED_LAYER_MARKER");
await expectDiffTextAbsent(testPage, "MOBILE_UNSTAGED_LAYER_MARKER");
await testPage.getByTestId("mobile-diff-sheet-close").tap();
await stagedRow.getByTitle("Unstage file").tap();
await expect(stagedRow).toHaveCount(0);
await expect(unstagedRow).toBeVisible();
await expect(unstagedRow.getByText("+2", { exact: true })).toBeVisible();
});
test("tapping a PR file row opens file diff sheet with diff content", async ({
testPage,
apiClient,
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
import { splitFilesByChangeLayer } from "./git-change-facets";
describe("splitFilesByChangeLayer", () => {
it("projects one mixed raw file into staged and unstaged views", () => {
const mixed = {
path: "src/mixed.ts",
status: "modified",
staged: false,
additions: 2,
diff: "combined",
staged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: "staged diff",
},
unstaged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: "unstaged diff",
},
} as FileInfo;
const result = splitFilesByChangeLayer([mixed]);
expect(result.stagedFiles).toEqual([
expect.objectContaining({
path: "src/mixed.ts",
staged: true,
change_layer: "staged",
additions: 1,
diff: "staged diff",
}),
]);
expect(result.unstagedFiles).toEqual([
expect.objectContaining({
path: "src/mixed.ts",
staged: false,
change_layer: "unstaged",
additions: 1,
diff: "unstaged diff",
}),
]);
expect(mixed.diff).toBe("combined");
});
it("keeps legacy files in exactly one section", () => {
const staged = {
path: "staged.ts",
status: "added",
staged: true,
} as FileInfo;
const unstaged = {
path: "unstaged.ts",
status: "modified",
staged: false,
} as FileInfo;
expect(splitFilesByChangeLayer([staged, unstaged])).toEqual({
stagedFiles: [staged],
unstagedFiles: [unstaged],
});
});
});
@@ -0,0 +1,51 @@
import type {
FileChangeFacet,
FileInfo,
GitChangeLayer,
} from "@/lib/state/slices/session-runtime/types";
function projectFileChange(
file: FileInfo,
layer: GitChangeLayer,
facet: FileChangeFacet,
): FileInfo {
return {
...file,
status: facet.status,
staged: layer === "staged",
additions: facet.additions,
deletions: facet.deletions,
old_path: facet.old_path,
diff: facet.diff,
diff_skip_reason: facet.diff_skip_reason,
change_layer: layer,
};
}
/**
* Builds section-specific file views while preserving one canonical raw file
* per repository/path for counts and mutations.
*/
export function splitFilesByChangeLayer(allFiles: FileInfo[]): {
stagedFiles: FileInfo[];
unstagedFiles: FileInfo[];
} {
const stagedFiles: FileInfo[] = [];
const unstagedFiles: FileInfo[] = [];
for (const file of allFiles) {
const hasFacets = Boolean(file.staged_change || file.unstaged_change);
if (!hasFacets) {
(file.staged ? stagedFiles : unstagedFiles).push(file);
continue;
}
if (file.staged_change) {
stagedFiles.push(projectFileChange(file, "staged", file.staged_change));
}
if (file.unstaged_change) {
unstagedFiles.push(projectFileChange(file, "unstaged", file.unstaged_change));
}
}
return { stagedFiles, unstagedFiles };
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { buildReviewSources } from "./use-review-sources";
describe("buildReviewSources mixed facets", () => {
it("preserves staged and unstaged facets on an uncommitted source", () => {
const result = buildReviewSources({
gitStatus: {
files: {
"src/mixed.ts": {
diff: "combined",
status: "modified",
staged: false,
staged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: "staged diff",
},
unstaged_change: {
status: "modified",
additions: 1,
deletions: 0,
diff: "unstaged diff",
},
} as never,
},
},
statusByRepo: undefined,
cumulativeDiff: null,
prDiffFiles: undefined,
});
expect(result.allFiles[0]).toMatchObject({
staged_change: { additions: 1, diff: "staged diff" },
unstaged_change: { additions: 1, diff: "unstaged diff" },
});
});
});
@@ -18,7 +18,7 @@ import {
suppressAvailableGitlinkFiles,
} from "@/components/review/types";
import { createDebugLogger } from "@/lib/debug/log";
import type { ReviewFile } from "@/components/review/types";
import type { ReviewChangeFacet, ReviewFile } from "@/components/review/types";
import type { PRDiffFile, TaskPR } from "@/lib/types/github";
import { normalizeFileChangeStatus } from "@/lib/utils/file-change-status";
import { prTaskKey } from "@/components/github/pr-utils";
@@ -38,6 +38,17 @@ type UncommittedFile = {
deletions?: number;
staged?: boolean;
is_submodule?: boolean;
staged_change?: UncommittedChangeFacet;
unstaged_change?: UncommittedChangeFacet;
};
type UncommittedChangeFacet = {
diff?: string;
diff_skip_reason?: ReviewFile["diff_skip_reason"];
status?: string;
old_path?: string;
additions?: number;
deletions?: number;
};
type CumulativeFile = {
@@ -78,12 +89,28 @@ function addUncommittedFiles(
source: "uncommitted",
old_path: file.old_path,
diff_skip_reason: skipReason,
staged_change: normalizeUncommittedFacet(file.staged_change),
unstaged_change: normalizeUncommittedFacet(file.unstaged_change),
repository_name: repositoryName,
is_submodule: file.is_submodule ?? isSubmodule,
});
}
}
function normalizeUncommittedFacet(
facet: UncommittedChangeFacet | undefined,
): ReviewChangeFacet | undefined {
if (!facet) return undefined;
return {
diff: facet.diff ? normalizeDiffContent(facet.diff) : "",
status: normalizeFileChangeStatus(facet.status),
additions: facet.additions ?? 0,
deletions: facet.deletions ?? 0,
old_path: facet.old_path,
diff_skip_reason: facet.diff_skip_reason,
};
}
function addCumulativeFiles(
fileMap: Map<string, ReviewFile>,
files: Record<string, CumulativeFile>,
@@ -79,6 +79,23 @@ describe("deriveMultiRepoSummary", () => {
]);
});
it("reports both layers for one mixed file", () => {
const mixed = {
...file("frontend"),
staged_change: { status: "modified" as const },
unstaged_change: { status: "modified" as const },
};
const result = deriveMultiRepoSummary([repoStatus("frontend")], [mixed], ["frontend"]);
expect(result.perRepoStatus).toEqual([
expect.objectContaining({
repository_name: "frontend",
hasStaged: true,
hasUnstaged: true,
}),
]);
});
it("keeps base divergence separate from upstream push and pull counts", () => {
const result = deriveMultiRepoSummary(
[
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
import { useSessionGitStatusByRepo } from "./use-session-git-status";
import { splitFilesByChangeLayer } from "./git-change-facets";
type RepositoryStatus = ReturnType<typeof useSessionGitStatusByRepo>[number];
@@ -69,10 +70,14 @@ export function deriveMultiRepoSummary(
const stagedByRepo = new Map<string, boolean>();
const unstagedByRepo = new Map<string, boolean>();
for (const file of allFiles) {
const { stagedFiles, unstagedFiles } = splitFilesByChangeLayer(allFiles);
for (const file of stagedFiles) {
const repositoryName = file.repository_name ?? "";
if (file.staged) stagedByRepo.set(repositoryName, true);
else unstagedByRepo.set(repositoryName, true);
stagedByRepo.set(repositoryName, true);
}
for (const file of unstagedFiles) {
const repositoryName = file.repository_name ?? "";
unstagedByRepo.set(repositoryName, true);
}
const hasNamed = statusByRepo.some((status) => status.repository_name !== "");
const filtered =
@@ -25,6 +25,7 @@ import { useMultiRepoSummary } from "./use-session-git-summary";
import { deriveComparisonValues, deriveSessionGitValues } from "./use-session-git-derived";
import { useScopedStageOperations } from "./use-scoped-stage-operations";
import { normalizeGitStatusFiles } from "@/lib/state/slices/session-runtime/git-status-normalizer";
import { splitFilesByChangeLayer } from "./git-change-facets";
/**
* Per-repo result emitted by frontend-side fan-outs (commit, push, pull,
@@ -626,8 +627,10 @@ function useFileDerivations(
allFilesCount: allFiles.length,
});
}, [statusByRepo, gitStatus, allFiles]);
const unstagedFiles = useMemo(() => allFiles.filter((f) => !f.staged), [allFiles]);
const stagedFiles = useMemo(() => allFiles.filter((f) => f.staged), [allFiles]);
const { stagedFiles, unstagedFiles } = useMemo(
() => splitFilesByChangeLayer(allFiles),
[allFiles],
);
const repoForPath = useMemo(() => {
const m = new Map<string, string>();
for (const f of allFiles) {
+18
View File
@@ -89,6 +89,24 @@ describe("buildGitFileSignature", () => {
} as FileInfo);
expect(before).not.toBe(after);
});
it("changes when only a mixed-change facet changes", () => {
const base = {
path: PATH,
status: "modified" as const,
staged: false,
diff: "combined",
staged_change: { status: "modified" as const, diff: "staged one" },
unstaged_change: { status: "modified" as const, diff: "unstaged" },
};
expect(buildGitFileSignature(base)).not.toBe(
buildGitFileSignature({
...base,
staged_change: { status: "modified", diff: "staged two" },
}),
);
});
});
describe("syncOpenFileFromWorkspace", () => {
+14
View File
@@ -10,6 +10,18 @@ import { updatePanelAfterSave } from "./use-file-save-delete";
import type { FileInfo } from "@/lib/state/store";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
function buildChangeFacetSignature(facet: FileInfo["staged_change"]): string {
if (!facet) return "";
return [
facet.status,
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
facet.diff ?? "",
facet.diff_skip_reason ?? "",
].join("\0");
}
/**
* Builds a stable signature string from a file's git status entry. The hook
* compares signatures across renders to decide whether the editor's content
@@ -24,6 +36,8 @@ export function buildGitFileSignature(file: FileInfo | undefined): string {
String(file.deletions ?? 0),
file.old_path ?? "",
file.diff ?? "",
buildChangeFacetSignature(file.staged_change),
buildChangeFacetSignature(file.unstaged_change),
].join("|");
}
@@ -403,6 +403,25 @@ describe("addFileDiffPanel — preview behavior", () => {
});
});
describe("addFileDiffPanel — mixed-layer identity", () => {
it("does not reuse a pinned diff for another layer of the same file", () => {
const { api, actions } = build(makeApi());
actions.addFileDiffPanel(SHARED_PATH, {
pin: true,
repositoryName: "frontend",
changeLayer: "staged",
});
actions.addFileDiffPanel(SHARED_PATH, {
repositoryName: "frontend",
changeLayer: "unstaged",
});
const preview = api.getPanel(PREVIEW_DIFF_ID) as unknown as MockPanel;
expect(preview).toBeDefined();
expect(preview.params.changeLayer).toBe("unstaged");
});
});
describe("addCommitDetailPanel — preview behavior", () => {
let api: DockviewApi;
let actions: ReturnType<typeof buildPanelActions>;
+13 -2
View File
@@ -1,5 +1,5 @@
import type { DockviewApi, DockviewGroupPanel } from "dockview-react";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import type { ChangeLayer, CommitDetailTarget } from "@/components/task/changes-diff-target";
import { t } from "@/lib/i18n";
import { focusOrAddPanel } from "./dockview-layout-builders";
import { reviewPanelId, type ReviewPanelTarget } from "./dockview-review-panel-id";
@@ -374,6 +374,11 @@ function buildFileEditorAction(get: StoreGet) {
};
}
function buildFileDiffItemId(path: string, scope: string | undefined, layer?: ChangeLayer): string {
const scopedItemId = buildRepoScopedItemId(path, scope);
return layer ? `${layer}:${scopedItemId}` : scopedItemId;
}
/**
* Build the `addFileDiffPanel` action: opens the file-diff preview (or pinned
* panel) for a path, passing content/source/repository/pr params through and
@@ -388,11 +393,16 @@ function buildFileDiffAction(get: StoreGet) {
source?: string;
repositoryName?: string;
prKey?: string;
changeLayer?: ChangeLayer;
},
) => {
const { api, centerGroupId } = get();
if (!api) return;
const itemId = buildRepoScopedItemId(path, opts?.prKey ?? opts?.repositoryName);
const itemId = buildFileDiffItemId(
path,
opts?.prKey ?? opts?.repositoryName,
opts?.changeLayer,
);
openOrReplacePreview({
api,
type: "file-diff",
@@ -405,6 +415,7 @@ function buildFileDiffAction(get: StoreGet) {
source: opts?.source,
repositoryName: opts?.repositoryName,
prKey: opts?.prKey,
changeLayer: opts?.changeLayer,
},
groupId: opts?.groupId ?? centerGroupId,
quiet: opts?.quiet,
+1
View File
@@ -149,6 +149,7 @@ type DockviewStore = {
source?: string;
repositoryName?: string;
prKey?: string;
changeLayer?: import("@/components/task/changes-diff-target").ChangeLayer;
},
) => void;
addCommitDetailPanel: (
@@ -5,6 +5,7 @@ import type {
SessionPollMode,
GitStatusEntry,
FileInfo,
FileChangeFacet,
} from "./types";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { normalizeGitStatusEntry } from "./git-status-normalizer";
@@ -50,10 +51,24 @@ const COMPARABLE_FILE_FIELDS = [
"old_path",
"diff",
"diff_skip_reason",
"staged_change",
"unstaged_change",
"repository_name",
"is_submodule",
] as const;
function comparableChangeFacet(facet: FileChangeFacet | undefined): string {
if (!facet) return "";
return [
facet.status,
facet.additions ?? 0,
facet.deletions ?? 0,
facet.old_path ?? "",
facet.diff ?? "",
facet.diff_skip_reason ?? "",
].join("\0");
}
function comparableFileInfo(file: FileInfo) {
return {
path: file.path,
@@ -64,6 +79,8 @@ function comparableFileInfo(file: FileInfo) {
old_path: file.old_path ?? "",
diff: file.diff ?? "",
diff_skip_reason: file.diff_skip_reason ?? "",
staged_change: comparableChangeFacet(file.staged_change),
unstaged_change: comparableChangeFacet(file.unstaged_change),
repository_name: file.repository_name ?? "",
is_submodule: file.is_submodule ?? false,
};
@@ -9,6 +9,8 @@ function makeStore() {
}
const SESSION = "sess-1";
const MODIFIED_STATUS = "modified" as const;
const NEWER_TIMESTAMP = "2026-05-28T00:01:00Z";
function status(overrides: Partial<GitStatusEntry> = {}): GitStatusEntry {
return {
@@ -22,7 +24,7 @@ function status(overrides: Partial<GitStatusEntry> = {}): GitStatusEntry {
ahead: 0,
behind: 0,
files: {
"a.ts": { path: "a.ts", status: "modified", staged: false, diff: "-old\n+new" },
"a.ts": { path: "a.ts", status: MODIFIED_STATUS, staged: false, diff: "-old\n+new" },
},
timestamp: "2026-05-28T00:00:00Z",
...overrides,
@@ -39,15 +41,15 @@ describe("setGitStatus change reporting (single deep compare)", () => {
it("returns true on the first status and false for an identical follow-up", () => {
expect(store.getState().setGitStatus(SESSION, status())).toBe(true);
// Same content, newer timestamp only — must not count as a change.
expect(
store.getState().setGitStatus(SESSION, status({ timestamp: "2026-05-28T00:01:00Z" })),
).toBe(false);
expect(store.getState().setGitStatus(SESSION, status({ timestamp: NEWER_TIMESTAMP }))).toBe(
false,
);
});
it("does not rewrite byEnvironmentId for a duplicate snapshot", () => {
store.getState().setGitStatus(SESSION, status());
const ref = store.getState().gitStatus.byEnvironmentId[SESSION];
store.getState().setGitStatus(SESSION, status({ timestamp: "2026-05-28T00:01:00Z" }));
store.getState().setGitStatus(SESSION, status({ timestamp: NEWER_TIMESTAMP }));
expect(store.getState().gitStatus.byEnvironmentId[SESSION]).toBe(ref);
});
@@ -57,13 +59,42 @@ describe("setGitStatus change reporting (single deep compare)", () => {
SESSION,
status({
files: {
"a.ts": { path: "a.ts", status: "modified", staged: false, diff: "-old\n+newer" },
"a.ts": { path: "a.ts", status: MODIFIED_STATUS, staged: false, diff: "-old\n+newer" },
},
}),
);
expect(changed).toBe(true);
});
it("returns true when only a mixed-change facet changes", () => {
const first = status({
files: {
"a.ts": {
path: "a.ts",
status: MODIFIED_STATUS,
staged: false,
diff: "combined",
staged_change: { status: MODIFIED_STATUS, diff: "staged one" },
unstaged_change: { status: MODIFIED_STATUS, diff: "unstaged" },
} as never,
},
});
store.getState().setGitStatus(SESSION, first);
const changed = store.getState().setGitStatus(SESSION, {
...first,
timestamp: NEWER_TIMESTAMP,
files: {
"a.ts": {
...first.files["a.ts"],
staged_change: { status: MODIFIED_STATUS, diff: "staged two" },
} as never,
},
});
expect(changed).toBe(true);
});
it("returns true when only the submodule marker changes", () => {
store.getState().setGitStatus(SESSION, status({ is_submodule: false }));
@@ -75,7 +106,7 @@ describe("setGitStatus change reporting (single deep compare)", () => {
SESSION,
status({
files: {
"frontend\u0000src/app.ts": { status: "modified", staged: false } as never,
"frontend\u0000src/app.ts": { status: MODIFIED_STATUS, staged: false } as never,
},
}),
);
@@ -96,7 +127,7 @@ describe("setGitStatus change reporting (single deep compare)", () => {
expect(
store
.getState()
.setGitStatus(SESSION, status({ is_submodule: true, timestamp: "2026-05-28T00:01:00Z" })),
.setGitStatus(SESSION, status({ is_submodule: true, timestamp: NEWER_TIMESTAMP })),
).toBe(false);
});
});
@@ -40,6 +40,17 @@ export type ProcessState = {
devProcessBySessionId: Record<string, string>;
};
export type GitChangeLayer = "staged" | "unstaged";
export type FileChangeFacet = {
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type FileInfo = {
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
@@ -49,6 +60,10 @@ export type FileInfo = {
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
staged_change?: FileChangeFacet;
unstaged_change?: FileChangeFacet;
/** Frontend-only projection used when one raw path appears in both change sections. */
change_layer?: GitChangeLayer;
/** Exact old-side ref for cumulative committed diffs. */
base_ref?: string;
/**
+11
View File
@@ -298,6 +298,15 @@ export type OfficeInboxItemNotificationPayload = {
body: string;
};
export type FileChangeFacet = {
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
additions?: number;
deletions?: number;
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
};
export type FileInfo = {
path: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
@@ -307,6 +316,8 @@ export type FileInfo = {
old_path?: string;
diff?: string;
diff_skip_reason?: "too_large" | "binary" | "truncated" | "budget_exceeded";
staged_change?: FileChangeFacet;
unstaged_change?: FileChangeFacet;
};
// Executor and environment payload types (extracted to reduce file size)
@@ -0,0 +1,77 @@
# ADR-2026-08-27-mixed-git-change-facets: Preserve mixed Git changes as path facets
**Status:** accepted
**Date:** 2026-08-27
**Area:** backend, agentctl, frontend, protocol
## Context
Git porcelain reports the index and working tree independently. A tracked path can therefore have a
staged edit and a later unstaged edit (`MM`), while a newly staged path can receive another unstaged
edit (`AM`). Kandev currently collapses both columns into one `FileInfo`. The parser prioritizes the
working-tree column, so the file appears only under Unstaged, and its `HEAD`-relative diff merges both
layers.
The file path is still one mutation and review identity. Duplicating the path in the status map would
distort changed-file counts, path-based stage operations, multi-repository keys, carry-forward logic,
and Review deduplication. Replacing the wire shape outright would also break stored snapshots and
older frontend consumers. The model needs to retain one path identity while exposing two independently
selectable change layers.
## Decision
`GitStatusUpdate.Files` remains a map with one entry per repository-relative path. `FileInfo` keeps
its existing flattened fields and adds two optional facet objects: `staged_change` and
`unstaged_change`. A facet carries status, additions, deletions, old path, diff content, and diff skip
reason.
Agentctl emits both facet objects when the porcelain index and working-tree columns both describe a
change. Single-layer paths retain the existing compact shape. For mixed paths, the flattened fields
remain the existing compatibility projection: worktree-priority classification plus the combined
`HEAD`-to-working-tree diff. The staged facet is computed from `HEAD` to the index, and the unstaged
facet from the index to the working tree.
New consumers use the facets when present and fall back to the flattened fields otherwise. Changes
may project two temporary rows, but raw status, Review aggregation, badges, and Git mutations remain
keyed once by `(repository, path)`. A diff target is keyed by `(repository, path, layer)` so opening
the staged and unstaged rows cannot resolve to the same content or pinned panel.
Every serialized diff representation participates in the existing total snapshot budget and retains
the existing per-representation output cap and skip reasons. Facet metadata survives even when its
diff content is skipped. Git unmerged/conflict states are not redefined by this decision.
## Consequences
- Desktop and mobile Changes can show one path in both Staged and Unstaged with accurate layer stats
and diffs.
- Existing snapshots and clients remain decodable because the flattened fields stay present and the
new fields are optional.
- Mixed entries can serialize more diff content than single-layer entries, but the shared snapshot
threshold still bounds total enrichment.
- Frontend equality checks, diff identities, and focus fingerprints must include the layer/facet data
or facet-only updates can be missed.
- Stage, unstage, discard, Review deduplication, and changed-file totals must never treat a projected
facet row as a second path mutation.
## Alternatives Considered
### Add two status-map entries with synthetic path keys
Rejected. Synthetic keys would leak into path operations, changed-file counts, multi-repository
identity, editor synchronization, and Review deduplication.
### Replace `FileInfo` with an array of layer records
Rejected. This is a breaking wire and stored-snapshot migration for a state that can be represented
additively. Older consumers would fail to decode or render ordinary status snapshots.
### Keep one record and concatenate staged and unstaged diffs
Rejected. A separator can make both hunks visible, but it cannot give each section accurate status,
line totals, selection identity, or staging transitions.
### Fetch a layer-specific diff only after row selection
Rejected for this repair. It avoids duplicate streamed diff content but adds a new request lifecycle,
loading and failure states, and cache invalidation path to a panel that already receives bounded diff
data. The existing snapshot budget is sufficient for the additive facet contract.
+1
View File
@@ -214,4 +214,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-27-bounded-agent-profile-recency | [Store Agent Profile Recency in Bounded Context Rows](2026-08-27-bounded-agent-profile-recency.md) | accepted | backend, frontend, protocol | 2026-08-27 |
| 2026-08-27-protect-deferred-launch-attribution | [Protect Deferred Launch Attribution](2026-08-27-protect-deferred-launch-attribution.md) | accepted | backend, protocol | 2026-08-27 |
| 2026-08-27-preserve-legacy-sqlite-before-default-initialization | [Preserve Legacy SQLite Data Before Default Initialization](2026-08-27-preserve-legacy-sqlite-before-default-initialization.md) | accepted | backend, cli, operations | 2026-08-27 |
| 2026-08-27-mixed-git-change-facets | [Preserve mixed Git changes as path facets](2026-08-27-mixed-git-change-facets.md) | accepted | backend, agentctl, frontend, protocol | 2026-08-27 |
| 2026-08-28-bind-github-auto-merge-attempts-to-reviewed-head | [Bind GitHub Auto-Merge Attempts to the Reviewed Head](2026-08-28-bind-github-auto-merge-attempts-to-reviewed-head.md) | accepted | backend, frontend, protocol, security, GitHub | 2026-08-28 |
@@ -0,0 +1,120 @@
---
created: 2026-08-27
status: completed
requirements:
- REQ-PLATFORM-WORKSPACE-GIT-STATUS-001
system_design:
- ../../specs/platform/system-design/workspace-git-status.md
legacy_specs: []
---
# Implementation Plan: Mixed Staged and Unstaged Changes
## Overview
Preserve both Git layers for one path, then project them into accurate Changes rows and diff targets.
Implementation starts with red backend, desktop, and mobile regressions; publishes the additive
agentctl contract next; then consumes it in the shared frontend so both platform layouts turn green.
## Scope
### In scope
- Preserve staged and unstaged facets for ordinary mixed porcelain states such as `MM` and `AM`.
- Keep one raw repository/path identity while rendering facet-specific rows, stats, and diffs.
- Route desktop and mobile diff selection by change layer.
- Preserve current file-level stage, unstage, discard, Review, and unique changed-file counts.
- Keep facet diff content inside the established status-snapshot budget.
### Out of scope
- Hunk-level staging.
- New conflict or unmerged-state semantics.
- A Changes panel visual redesign.
- A new on-demand diff API.
## Technical approach
### Agentctl status contract
Add `FileChangeFacet` plus optional `StagedChange` and `UnstagedChange` fields to
`apps/backend/internal/agentctl/types/streams/git.go`. Refactor `applyPorcelainLine` in
`workspace_git_status.go` to interpret the `X` and `Y` columns independently while preserving the
one-path compatibility projection.
Extend `workspace_git_diff.go` so a mixed path retains its existing `HEAD`-relative flattened diff,
gets a staged `git diff --cached HEAD -- <path>` facet, and gets an unstaged
`git diff -- <path>` facet. Teach diff-budget accounting and same-HEAD carry-forward to traverse the
optional facets. No facet diff may bypass the shared threshold or existing skip-reason vocabulary.
### Frontend projection
Mirror the optional facet type in both frontend wire models. Add a pure projection helper under
`apps/web/hooks/domains/session/` that returns staged and unstaged `FileInfo` views for Changes while
keeping `allFiles` unique. `useSessionGit` and multi-repository summaries use those projected arrays
for section and action availability. Store equality, editor synchronization, and Changes focus
fingerprints include facet data so a layer-only update is observable.
Review's aggregate uncommitted source continues to use the flattened compatibility file. Mutation
dispatch continues to receive only repository plus path, even when both projected rows exist.
### Layer-qualified diff selection
Add a staged/unstaged layer to `OpenDiffOptions` and file-mode `DiffSheetMode`. Propagate it through
Changes rows, TaskChangesPanel selection, Dockview preview/pinned IDs, and the mobile diff drawer.
Layer-qualified selections project the matching facet; legacy and non-layer selections keep the
combined uncommitted view. The same shared Changes body remains in desktop and mobile layouts.
This approach implements
[ADR-2026-08-27-mixed-git-change-facets](../../decisions/2026-08-27-mixed-git-change-facets.md).
## Tests
- `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.7` and `.9`: add
`apps/backend/internal/agentctl/server/process/workspace_git_mixed_changes_test.go` covering `MM`,
`AM`, independent numstat/diff content, carry-forward, and shared budget accounting.
- `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10` and `.11`: add
`apps/web/hooks/domains/session/git-change-facets.test.ts` for unique raw identity, two projections,
layer stats, and mutation-path deduplication; extend Git summary and status equality tests.
- `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10`: extend
`apps/web/components/task/task-changes-panel.test.ts` and
`apps/web/lib/state/dockview-panel-actions.test.ts` for layer-specific content and panel identity.
- `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12`: add focused mobile component coverage proving the same
facet target reaches the existing full-height diff drawer.
## E2E tests
- Desktop, `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10` and `.11`: extend
`apps/web/e2e/tests/git/git-changes-panel.spec.ts` with a tracked `MM` file. Assert the same path is
present in both sections, each row shows its own line count and diff, and unstage leaves the
unstaged facet.
- Mobile, `AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12`: extend
`apps/web/e2e/tests/task/mobile-changes-panel.spec.ts` with the same setup and assert both sections,
layer-specific drawer content, and touch staging behavior.
## Work orders
- [x] [Task 01: Capture mixed-change regression](task-01-capture-mixed-change-regression.md)
- [x] [Task 02: Publish mixed-change facets](task-02-publish-mixed-change-facets.md)
- [x] [Task 03: Render layer-specific changes](task-03-render-layer-specific-changes.md)
## Verification results
- Task 01 RED evidence captured. Agentctl omitted `staged_change`; desktop and mobile could not find
the staged section for the mixed path.
- Task 02 published additive staged and unstaged facets with independent diffs, bounded shared
accounting, and budget-aware carry-forward. The focused and package-level Go suites passed.
- Task 03 projected one raw path into two layer-qualified rows and diff targets while retaining
unique totals and mutation identity. Focused frontend tests, typecheck, lint guards, and fresh
desktop/mobile Playwright regressions passed, including the unstage transition.
## Risks
- Compatibility and facet diffs duplicate content for mixed paths; incorrect budget accounting could
exceed the bounded snapshot contract.
- A path shown twice can accidentally double-count badges or dispatch the same mutation twice unless
raw identity and projected presentation remain separate.
- Omitting the layer from a Dockview or mobile selection key would reopen the bug by showing the
combined or wrong facet diff.
- Rename/delete combinations use different porcelain and numstat path shapes; parser tests must prove
that ordinary supported mixed states retain the correct final path.
@@ -0,0 +1,85 @@
---
id: "01-capture-mixed-change-regression"
title: "Capture mixed-change regression"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLATFORM-WORKSPACE-GIT-STATUS-001
acceptance_criteria:
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12
system_design:
- ../../specs/platform/system-design/workspace-git-status.md
---
# Task 01: Capture mixed-change regression
## Summary
Create permanent backend, desktop, and mobile tests that reproduce the confirmed `MM` failure before
production changes. Record red results showing that the staged facet is missing and the diff is
combined.
## In scope
- Add a focused backend mixed-status test file.
- Add one desktop and one mobile Playwright regression using a real tracked file.
- Capture the expected red reason for each test before implementation.
## Out of scope
- Production contract or UI changes.
- Broad E2E suite execution.
## Acceptance
- The backend test expects independent staged and unstaged facets from one path.
- Both browser tests expect the same path in both sections and layer-specific diff content.
- Before correction, each targeted test fails only because current code collapses the two layers.
## Verification
```bash
cd apps/backend
go test ./internal/agentctl/server/process -run 'TestWorkspaceGitStatusPreservesMixedChangeFacets'
cd apps/web
pnpm e2e:run tests/git/git-changes-panel.spec.ts -- --grep "same path in staged and unstaged sections"
pnpm e2e:run --project mobile-chrome tests/task/mobile-changes-panel.spec.ts -- --grep "same path in staged and unstaged sections"
```
The commands are expected to fail at this red checkpoint. Save the precise assertion failures in the
work-order Results section before continuing.
## Files likely touched
- `apps/backend/internal/agentctl/server/process/workspace_git_mixed_changes_test.go`
- `apps/web/e2e/tests/git/git-changes-panel.spec.ts`
- `apps/web/e2e/tests/task/mobile-changes-panel.spec.ts`
## Dependencies
None.
## Risks
- E2E setup must mutate only its disposable fixture worktree and wait on observable status updates.
## Parallelism
`sequential`
## Inputs
- Requirement acceptance criteria `.9`, `.10`, and `.12`.
- Existing workspace Git process tests and desktop/mobile Changes E2E fixtures.
- Manual reproduction evidence from `MM README.md` and `AM mixed-stage-panel-test.txt`.
## Results
- `go test -json ./internal/agentctl/server/process -run '^TestWorkspaceGitStatusPreservesMixedChangeFacets$' -count=1` failed at the intended assertion: the `FileInfo` wire had one combined `+2` diff and no `staged_change` facet.
- Desktop Chromium failed at the intended assertion: `staged-files-section-collapse-toggle` was absent.
- Mobile Chrome failed twice at the same intended assertion: `staged-files-section-collapse-toggle` was absent.
@@ -0,0 +1,88 @@
---
id: "02-publish-mixed-change-facets"
title: "Publish mixed-change facets"
status: completed
wave: 2
depends_on:
- "01-capture-mixed-change-regression"
plan: "plan.md"
requirements:
- REQ-PLATFORM-WORKSPACE-GIT-STATUS-001
acceptance_criteria:
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.7
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.8
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9
system_design:
- ../../specs/platform/system-design/workspace-git-status.md
---
# Task 02: Publish mixed-change facets
## Summary
Extend the agentctl status wire additively and populate independent staged and unstaged metadata for
mixed paths. Preserve legacy fields and the established enrichment limits.
## In scope
- Add the optional facet type and fields to `FileInfo`.
- Parse index and working-tree porcelain columns independently for ordinary supported states.
- Enrich `HEAD -> index`, `index -> worktree`, and flattened compatibility diffs.
- Include facet content in budget, truncation, carry-forward, and skip-reason logic.
## Out of scope
- Frontend facet rendering.
- New behavior for unmerged/conflict states.
- New Git routes.
## Acceptance
- `MM` and `AM` produce one path entry with both accurate facets.
- Pure staged, pure unstaged, and untracked paths retain the legacy compact representation.
- Facet content cannot bypass the 256 KiB representation cap or shared 2 MiB threshold.
## Verification
```bash
cd apps/backend
go test ./internal/agentctl/server/process -run 'TestWorkspaceGitStatusPreservesMixedChangeFacets|TestMixedChangeFacetDiffs|TestMixedChangeFacetBudget|TestCarryForwardMixedChangeFacets'
go test ./internal/agentctl/types/streams ./internal/agentctl/server/process
```
## Files likely touched
- `apps/backend/internal/agentctl/types/streams/git.go`
- `apps/backend/internal/agentctl/server/process/workspace_git_status.go`
- `apps/backend/internal/agentctl/server/process/workspace_git_diff.go`
- `apps/backend/internal/agentctl/server/process/workspace_git_mixed_changes_test.go`
- `apps/backend/internal/agentctl/server/process/workspace_git_diff_test.go`
## Dependencies
Task 01 red regression evidence.
## Risks
- Rename numstat paths and compatibility projections can address different old/new names.
- Carrying prior facet content after an enrichment failure must not overwrite a fresh sibling facet.
## Parallelism
`sequential`
## Inputs
- Requirement acceptance criteria `.7`, `.8`, and `.9`.
- Mixed-change section of the workspace Git status design.
- ADR-2026-08-27-mixed-git-change-facets.
## Results
- Added optional `staged_change` and `unstaged_change` wire facets while preserving the flattened
compatibility fields and compact pure-change representation.
- Parsed supported porcelain index/worktree states independently and enriched mixed paths with
`HEAD -> index` and `index -> worktree` diffs.
- Shared representation accounting now includes both facets; same-HEAD carry-forward preserves each
facet independently without bypassing an exhausted budget.
- `go test ./internal/agentctl/types/streams ./internal/agentctl/server/process` passed.
@@ -0,0 +1,114 @@
---
id: "03-render-layer-specific-changes"
title: "Render layer-specific changes"
status: completed
wave: 3
depends_on:
- "02-publish-mixed-change-facets"
plan: "plan.md"
requirements:
- REQ-PLATFORM-WORKSPACE-GIT-STATUS-001
acceptance_criteria:
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.11
- AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12
system_design:
- ../../specs/platform/system-design/workspace-git-status.md
---
# Task 03: Render layer-specific changes
## Summary
Consume mixed facets as two presentation rows while retaining one raw file identity and one mutation
target. Carry the selected layer through desktop Dockview and the existing mobile diff drawer, then
turn the red browser regressions green.
## In scope
- Mirror and project the optional facet contract in frontend state.
- Derive staged/unstaged sections and action availability from projected facets.
- Keep overall counts, Review aggregation, and mutation dispatch unique by repository/path.
- Include facet data in status equality, editor synchronization, and focus fingerprints.
- Add layer to desktop and mobile diff targets and pinned identities.
- Add focused unit/component tests and satisfy both Playwright regressions.
## Out of scope
- New layout, copy, controls, or localization keys.
- Hunk staging.
- Changing the combined Review source.
## Acceptance
- One mixed raw entry produces accurate Staged and Unstaged rows without doubling the overall file
count or Git mutation.
- Selecting either row renders only its facet and may coexist with the other layer as a pinned diff.
- Desktop and mobile targeted E2E tests pass using the same backend snapshot contract.
## Verification
```bash
cd apps
pnpm --filter @kandev/web test -- hooks/domains/session/git-change-facets.test.ts hooks/domains/session/use-session-git-grouping.test.ts hooks/domains/session/use-session-git-summary.test.ts lib/state/slices/session-runtime/set-git-status-return.test.ts components/task/changes-panel-focus.test.ts components/task/task-changes-panel.test.ts lib/state/dockview-panel-actions.test.ts components/task/mobile/mobile-changes-panel.test.tsx
cd apps/web
pnpm run typecheck
pnpm e2e:run tests/git/git-changes-panel.spec.ts -- --grep "same path in staged and unstaged sections"
pnpm e2e:run --project mobile-chrome tests/task/mobile-changes-panel.spec.ts -- --grep "same path in staged and unstaged sections"
pnpm run lint:e2e-sleeps
```
## Files likely touched
- `apps/web/lib/state/slices/session-runtime/types.ts`
- `apps/web/lib/types/backend.ts`
- `apps/web/hooks/domains/session/git-change-facets.ts`
- `apps/web/hooks/domains/session/git-change-facets.test.ts`
- `apps/web/hooks/domains/session/use-session-git.ts`
- `apps/web/hooks/domains/session/use-session-git-summary.ts`
- `apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts`
- `apps/web/components/task/changes-panel-focus.ts`
- `apps/web/components/task/changes-diff-target.ts`
- `apps/web/components/task/task-changes-panel.tsx`
- `apps/web/components/task/dockview-panel-content.tsx`
- `apps/web/components/task/dockview-shared.tsx`
- `apps/web/lib/state/dockview-panel-actions.ts`
- `apps/web/lib/state/dockview-store.ts`
- `apps/web/components/task/mobile/mobile-changes-panel.tsx`
- `apps/web/components/task/mobile/mobile-diff-sheet.tsx`
- `apps/web/e2e/tests/git/git-changes-panel.spec.ts`
- `apps/web/e2e/tests/task/mobile-changes-panel.spec.ts`
## Dependencies
Task 02 additive wire contract and green backend regression.
## Risks
- Preview and pinned panel IDs must include the layer without breaking legacy file and commit targets.
- Selected-diff compatibility paths can reopen combined-diff behavior if they omit the layer.
## Parallelism
`sequential`
## Inputs
- Requirement acceptance criteria `.10`, `.11`, and `.12`.
- Mixed-change and mobile-parity sections of the workspace Git status design.
- Existing shared Changes body, Dockview preview machinery, and mobile diff drawer.
## Results
- Added shared facet projection and layer-qualified desktop/mobile diff targets while preserving one
aggregate Review file and one repository/path mutation target.
- Included facet updates in state equality, editor synchronization, focus fingerprints, summary
projection, and Dockview identity.
- Coarse-pointer tree rows now expose an always-visible 44px stage action; fine-pointer hover layout
remains unchanged.
- Focused Vitest coverage, frontend typecheck, targeted ESLint, i18n ratchet, and E2E sleep ratchet
passed. The repository-wide `lint:e2e-sleeps` command still reports unrelated baseline violations;
both changed E2E files pass its dedicated ESLint configuration.
- Fresh desktop Chromium and mobile Chrome regressions passed, proving independent row counts and
diffs plus the unstage transition that removes only the staged facet.
@@ -2,7 +2,7 @@
status: active
system: platform
created: 2026-07-19
updated: 2026-08-19
updated: 2026-08-27
owners:
- kandev
---
@@ -26,8 +26,12 @@ Users opening or focusing Changes and Review need a current workspace snapshot w
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.4:** Every non-cancelled caller receives the same completed snapshot or error from a shared observation. A caller whose own context is cancelled returns promptly without cancelling or otherwise poisoning the result for other callers.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.5:** Tracker shutdown or the bounded shared-observation deadline cancels the underlying work. Cancelled work does not publish or cache a partial snapshot.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.6:** After Git output is parsed, changed-file and synthetic untracked-diff enrichment performs work proportional to the number of changed entries plus the bounded content processed.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.7:** Existing diff limits remain in force: 10 MiB maximum source file size, 256 KiB maximum emitted diff per file, and a 2 MiB enrichment threshold per status snapshot. Because the threshold is checked before enriching each file, the final accepted file may preserve the existing overshoot of up to the 256 KiB per-file cap. Existing skip reasons remain unchanged.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.7:** Existing diff limits remain in force: 10 MiB maximum source file size, 256 KiB maximum per emitted diff representation, and a 2 MiB enrichment threshold per status snapshot. Flattened compatibility diffs and layer-specific diffs all participate in the same snapshot threshold. Because the threshold is checked before enriching each representation, the final accepted representation may preserve the existing overshoot of up to the 256 KiB cap. Existing skip reasons remain unchanged.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.8:** Large changed sets retain every path and its status metadata. Once the total diff budget is exhausted, files that are not enriched retain `budget_exceeded` as their diff skip reason.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.9:** When one path has both index and working-tree changes, the workspace snapshot preserves the staged and unstaged change facets independently, including each facet's status, line totals, rename origin, diff content, and diff skip reason when applicable.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.10:** Changes lists a mixed path in both Staged and Unstaged, with facet-specific status and line totals. Opening either row shows only that layer's diff. Overall changed-file totals continue to count the repository and path once.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.11:** Staging or unstaging a mixed path operates once on its repository-qualified path and the next snapshot removes the consumed facet while preserving any facet that remains.
- **AC-PLATFORM-WORKSPACE-GIT-STATUS-001.12:** Desktop and mobile Changes surfaces expose the same mixed-path sections, facet-specific diff selection, and staging actions without replacing their established platform-native layouts.
## System design
@@ -4,7 +4,7 @@ system: platform
requirements:
- REQ-PLATFORM-WORKSPACE-GIT-STATUS-001
created: 2026-07-19
updated: 2026-08-19
updated: 2026-08-27
owners:
- kandev
---
@@ -18,7 +18,7 @@ This design preserves the technical source detail for `REQ-PLATFORM-WORKSPACE-GI
| Requirement | Design section |
| --- | --- |
| `REQ-PLATFORM-WORKSPACE-GIT-STATUS-001` | [Migrated source detail](#migrated-source-detail) |
| `REQ-PLATFORM-WORKSPACE-GIT-STATUS-001` | [Migrated source detail](#migrated-source-detail), [Mixed index and working-tree changes](#mixed-index-and-working-tree-changes), [API surface](#api-surface) |
## Migrated source detail
@@ -34,11 +34,52 @@ Users opening or focusing Changes and Review need a current workspace snapshot w
- Every non-cancelled caller receives the same completed snapshot or error from a shared observation. A caller whose own context is cancelled returns promptly without cancelling or otherwise poisoning the result for other callers.
- Tracker shutdown or the bounded shared-observation deadline cancels the underlying work. Cancelled work does not publish or cache a partial snapshot.
- After Git output is parsed, changed-file and synthetic untracked-diff enrichment performs work proportional to the number of changed entries plus the bounded content processed.
- Existing diff limits remain in force: 10 MiB maximum source file size, 256 KiB maximum emitted diff per file, and a 2 MiB enrichment threshold per status snapshot. Because the threshold is checked before enriching each file, the final accepted file may preserve the existing overshoot of up to the 256 KiB per-file cap. Existing skip reasons remain unchanged.
- Existing diff limits remain in force: 10 MiB maximum source file size, 256 KiB maximum per emitted diff representation, and a 2 MiB enrichment threshold per status snapshot. Every flattened or layer-specific representation participates in that threshold. Because the threshold is checked before enriching each representation, the final accepted representation may preserve the existing overshoot of up to the 256 KiB cap. Existing skip reasons remain unchanged.
- Large changed sets retain every path and its status metadata. Once the total diff budget is exhausted, files that are not enriched retain `budget_exceeded` as their diff skip reason.
- Multi-repository responses retain repository identity and partial-success behavior.
- Verification tooling preserves shared managed Go and lint caches for reuse while keeping invocation scratch and command output outside repository worktrees. The root-level `.verify-cache` and `.tmp` paths are ignored as safeguards against legacy or misconfigured verification runs.
### Mixed index and working-tree changes
Git porcelain reports two independent status columns for each path: the index state (`X`) and the
working-tree state (`Y`). A path such as `MM README.md` or `AM new-file.txt` is one file identity with
two change facets. The workspace model MUST preserve both columns instead of selecting one as the
file's only state.
`GitStatusUpdate.Files` remains keyed once by repository-relative path. `FileInfo` retains its
flattened fields as a compatibility projection and adds optional `staged_change` and
`unstaged_change` objects. Each object carries the facet's status, additions, deletions, old path,
diff, and diff skip reason. Agentctl emits the facet objects for a mixed path; a path with only one
layer continues to use the existing flattened representation. Consumers MUST accept both shapes.
For a mixed path, agentctl computes three intentional views:
- the flattened compatibility view compares `HEAD` with the working tree and preserves the existing
worktree-priority `staged=false` classification for older consumers;
- `staged_change` compares `HEAD` with the index using `git diff --cached`; and
- `unstaged_change` compares the index with the working tree using `git diff` without a base ref.
All serialized diff strings, including the compatibility projection, participate in the existing
snapshot-wide enrichment budget. Each representation retains the existing output cap and skip
reasons. Budget exhaustion may omit one representation without discarding either facet's status
metadata.
The frontend keeps the raw file map unique by `(repository_name, path)` for task badges, Review
aggregation, mutation routing, and change-file totals. Changes derives two temporary `FileInfo`
views from a mixed entry, one per facet, and falls back to the flattened fields when facet objects
are absent. Stage, unstage, and discard operations continue to send only the repository-qualified
path, never the temporary view identity.
Diff navigation adds the change layer to its target identity. Desktop preview and pinned diff tabs
use `(repository_name, path, layer)` so the staged and unstaged views of one path cannot alias each
other. The mobile full-height diff drawer carries the same layer while retaining its existing sheet,
touch-target, and scroll behavior. Review's all-files source remains a single combined uncommitted
file; only a layer-qualified Changes selection projects a facet-specific diff.
Status equality, editor synchronization signatures, and Changes focus fingerprints include the
optional facet fields. A snapshot that changes only one facet must therefore publish and render.
This contract follows [ADR-2026-08-27-mixed-git-change-facets](../../../decisions/2026-08-27-mixed-git-change-facets.md).
### Base-commit staleness and refresh
The commits panel (`git log <base>..HEAD`) and cumulative diff anchor to a base commit derived from the session's stored `base_commit_sha` and `base_branch`. That anchor becomes stale when the branch history moves relative to the true integration branch after the base was recorded — most commonly a stacked-PR parent that merges and disappears, a rebase onto the integration branch, or a base branch that was deleted upstream. When the anchor is stale, the panel enumerates commits that are not part of the branch's own contribution, inflating the count.
@@ -73,6 +114,7 @@ Existing Git-status routes remain in place. Their result and stream payloads add
- `GET /api/v1/git/status/multi?fresh=<bool>` returns the existing `MultiRepoGitStatusResult` shape containing `PerRepoGitStatus` entries.
- The `fresh` query parameter continues to select a live observation rather than a cached tracker snapshot.
- A repository status MAY include `comparison_target` with its credential-free display identity and `comparison_error_code` when the explicit target is unavailable.
- A mixed-path `FileInfo` MAY include `staged_change` and `unstaged_change`. Each facet uses the same bounded status, line-total, rename, diff, and skip-reason vocabulary as the flattened file fields. Their absence preserves the legacy single-layer shape.
- Commit and cumulative-diff requests use agentctl's configured repository-qualified ref when present. A caller-supplied branch name cannot override it.
- The internal agentctl control API adds a per-repository comparison-target update alongside the existing base-branch update. It validates identities and refs, materializes the exact ref, replaces tracker state, and triggers a refresh.
- The bounded task Git summary adds `comparison_unavailable`; when true, additions/deletions are not rendered as authoritative task-card statistics.
@@ -83,6 +125,8 @@ Existing Git-status routes remain in place. Their result and stream payloads add
|---|---|
| Primary branch or porcelain observation fails | The live observation fails and the prior cached snapshot remains available. |
| Secondary diff enrichment fails | The established same-HEAD carry-forward behavior is preserved. |
| A path has both index and working-tree changes | One raw file entry carries both facets. Changes renders one row in Staged and one in Unstaged, while overall file totals count the path once. |
| A mixed path's layer diff exceeds its cap or the snapshot budget | The affected facet retains status metadata and reports the existing `truncated` or `budget_exceeded` skip reason; the other facet remains independently usable. |
| One caller cancels while a shared observation is running | That caller returns its context cancellation promptly; other callers remain eligible to receive the shared result. |
| The tracker stops or the shared deadline expires | Underlying work is cancelled and no partial result is published or cached. |
| One repository fails during a multi-repository request | Successful repository entries remain available and the failure is reported on its repository entry. |
@@ -104,6 +148,9 @@ Existing Git-status routes remain in place. Their result and stream payloads add
- **GIVEN** one waiter cancels during a shared observation, **WHEN** other waiters remain, **THEN** the cancelled waiter returns promptly and the remaining waiters receive the completed result.
- **GIVEN** tracker shutdown or the shared-observation deadline while enrichment is running, **WHEN** cancellation reaches the observation, **THEN** filesystem iteration stops and no partial snapshot is cached.
- **GIVEN** approximately 15,000 untracked text files, **WHEN** fresh status is computed, **THEN** every path is present, emitted diff content obeys the existing limits, files not enriched after total-budget exhaustion have `budget_exceeded`, and post-porcelain enrichment remains linear in the number of entries.
- **GIVEN** a tracked file with one staged edit and a different unstaged edit, **WHEN** fresh status is computed, **THEN** its one path entry contains staged and unstaged facets with independent line totals and `HEAD -> index` and `index -> working tree` diffs.
- **GIVEN** a newly added staged file that receives another unstaged edit, **WHEN** Changes opens on desktop or mobile, **THEN** the same path appears in both Staged and Unstaged, selecting each row shows only that layer, and the overall changed-file count remains one.
- **GIVEN** a mixed path visible in both sections, **WHEN** the user stages or unstages it, **THEN** one repository-qualified Git mutation runs and the next snapshot shows only the facet that remains.
- **GIVEN** one invalid repository in a multi-repository request, **WHEN** other repositories succeed, **THEN** the response retains the successful entries and reports the failure only on the invalid repository.
- **GIVEN** verification needs writable scratch space, **WHEN** it selects a location, **THEN** the location is outside every Git worktree and existing shared caches remain reusable; if a legacy run creates root-level `.verify-cache` or `.tmp`, Git status ignores it.
- **GIVEN** a session whose stored `base_commit_sha` is a strict ancestor of `merge-base(HEAD, <resolved integration candidate>)` (for example a stacked-PR parent branch that has since merged into the integration branch and lost its upstream ref), **WHEN** the commits panel is requested, **THEN** the enumerated commit count matches `git rev-list --first-parent --count $(git merge-base HEAD <resolved integration candidate>)..HEAD` and excludes the commits that already landed on the integration branch.
@@ -131,7 +178,10 @@ Existing Git-status routes remain in place. Their result and stream payloads add
- Granting a new Git credential scope for a private comparison repository.
- Periodically fetching a moving target branch on every status poll. Targets are refreshed on association, retarget, launch/resume, and explicit comparison refresh.
- Automatically changing the checkout, push remote, upstream, or `origin` because a comparison target changes.
- Redesigning the desktop Changes panel or compressing it into the mobile layout.
- Adding hunk-level staging or changing the existing whole-file stage, unstage, and discard semantics.
- Defining new behavior for Git's unmerged/conflict porcelain states; those continue to follow their existing handling.
## Implementation plan
See [Workspace Git Status Scalability plan](../../../plans/workspace-git-status-scalability/plan.md) and [Fork PR Comparison Targets plan](../../../plans/fork-pr-comparison-targets/plan.md).
See [Workspace Git Status Scalability plan](../../../plans/workspace-git-status-scalability/plan.md), [Fork PR Comparison Targets plan](../../../plans/fork-pr-comparison-targets/plan.md), and [Mixed Staged and Unstaged Changes plan](../../../plans/mixed-staged-unstaged-changes/plan.md).