Skip to content

Commit 4a44e08

Browse files
authored
Respect (linguist|gitlab)-generated in .gitattributes (#115)
This PR adds support for `.gitattributes` files being able to either consider a file as generated, or remove a typically generated file (i.e in folders such as `out`) from being considered as generated. This is complimentary to the existing path-based heuristics. Both negation (`-linguist-generated`) and a `false` value (`linguist-generated=false`) are supported. `git check-attr --source=` is used but if the Git version is older than 2.40, when [the `--source` option was introduced](https://github.blog/open-source/git/highlights-from-git-2-40/#:~:text=While%20we%E2%80%99re%20talking%20about%20scripting), it will fall-back to the previous method of generating an index first with `git read-tree`. Files marked as generated are collapsed by default and given a small `Generated` badge: <img width="3211" height="390" alt="image" src="https://github.com/user-attachments/assets/4224a9e1-5c81-4ee5-acd5-46cff95de674" /> The walkthrough agent will group these generated files in the final support section but if they are deemed to be behaviourally important, it may still be included in a named section.
1 parent 748f4b6 commit 4a44e08

17 files changed

Lines changed: 463 additions & 27 deletions

bin/walkthrough-guide.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ choose.
3939
- **`support[]`** — changed hunks that should stay off the main path. Use it for generated files,
4040
lockfiles, snapshots, docs-only changes, and repeated mechanical edits unless they are essential
4141
to review. Codiff adds any omitted live-diff hunks to support. Generated-like files are one
42-
synthetic hunk per changed section; never split them, but keep behavior-relevant snapshots or
43-
artifacts on the main path.
42+
synthetic hunk per changed section; never split them, use `reason: "Generated files"` for
43+
generated-only support items so they render together, but keep behavior-relevant snapshots or
44+
artifacts on the main path. Codiff also recognizes `linguist-generated` and `gitlab-generated`
45+
attributes from `.gitattributes`.
4446
- **`changeType?` / `commitNote?`** — optional commit composer metadata for committable
4547
walkthroughs.
4648
- **`commit?`** — for working-tree walkthroughs, include `title` and `body` when there is enough

core/App.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2713,6 +2713,7 @@ html[data-codiff-platform='darwin'] .sidebar {
27132713

27142714
.codiff-status-badge,
27152715
.codiff-section-badge,
2716+
.codiff-generated-badge,
27162717
.codiff-line-count,
27172718
.codiff-file-comment-button,
27182719
.codiff-load-button,
@@ -2726,13 +2727,19 @@ html[data-codiff-platform='darwin'] .sidebar {
27262727

27272728
.codiff-status-badge,
27282729
.codiff-section-badge,
2730+
.codiff-generated-badge,
27292731
.codiff-line-count {
27302732
background: rgb(127 127 127 / 0.11);
27312733
color: var(--muted);
27322734
padding: 5px 9px;
27332735
white-space: nowrap;
27342736
}
27352737

2738+
.codiff-generated-badge {
2739+
background: color-mix(in srgb, var(--sidebar-ref) 15%, transparent);
2740+
color: var(--sidebar-ref);
2741+
}
2742+
27362743
.codiff-line-count {
27372744
display: inline-flex;
27382745
font-family: var(--font-mono);

core/App.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
} from './lib/diff.ts';
7171
import { compactPath, fuzzyMatches, sortFiles } from './lib/files.ts';
7272
import { isNativeInputTarget } from './lib/keyboard.ts';
73+
import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js';
7374
import { buildCommitModel, buildGenericCommitModel } from './lib/narrative-walkthrough.ts';
7475
import {
7576
consumeReloadSelection,
@@ -228,6 +229,7 @@ const getReloadSourceForLaunch = (
228229

229230
export default function App() {
230231
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
232+
const [expandedGenerated, setExpandedGenerated] = useState<Set<string>>(() => new Set());
231233
const [activeDiffSearchMatchIndex, setActiveDiffSearchMatchIndex] = useState(0);
232234
const [diffSearchFocusRequest, setDiffSearchFocusRequest] = useState(0);
233235
const [diffSearchQuery, setDiffSearchQuery] = useState('');
@@ -301,6 +303,7 @@ export default function App() {
301303
const stateRef = useRef<RepositoryState | null>(null);
302304
const activeReviewCommandTargetRef = useRef<ReviewCommandTarget | null>(null);
303305
const collapsedRef = useRef<Set<string>>(new Set());
306+
const expandedGeneratedRef = useRef<Set<string>>(new Set());
304307
const preferencesRef = useRef<CodiffPreferences>(defaultPreferences);
305308
const reviewCommentsRef = useRef<ReadonlyArray<ReviewComment>>([]);
306309
const selectedPathRef = useRef<string | null>(null);
@@ -558,6 +561,13 @@ export default function App() {
558561
}
559562
return next;
560563
});
564+
if (nextViewed) {
565+
setExpandedGenerated((current) => {
566+
const next = new Set(current);
567+
next.delete(file.path);
568+
return next;
569+
});
570+
}
561571
bumpItemVersion(file.path);
562572
},
563573
[bumpItemVersion],
@@ -571,6 +581,7 @@ export default function App() {
571581

572582
sourceSessionsRef.current.set(getSourceKey(currentState.source), {
573583
collapsed: new Set(collapsedRef.current),
584+
expandedGenerated: new Set(expandedGeneratedRef.current),
574585
narrativeWalkthrough: narrativeWalkthroughRef.current,
575586
reviewComments: reviewCommentsRef.current,
576587
selectedPath: selectedPathRef.current,
@@ -733,6 +744,7 @@ export default function App() {
733744
setState(orderedState);
734745
setLoadError(null);
735746
setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed));
747+
setExpandedGenerated(new Set());
736748
setItemVersionByKey({});
737749
setFocusCommentId(null);
738750
setFocusCommentRequest(0);
@@ -1026,6 +1038,7 @@ export default function App() {
10261038
setReviewComments(getReviewCommentsFromState(orderedState));
10271039
setViewed(nextViewed);
10281040
setCollapsed(getCollapsedViewedPaths(orderedState.files, nextViewed));
1041+
setExpandedGenerated(new Set());
10291042
setLoadError(null);
10301043
})
10311044
.catch((error: unknown) => {
@@ -1097,6 +1110,10 @@ export default function App() {
10971110
collapsedRef.current = collapsed;
10981111
}, [collapsed]);
10991112

1113+
useEffect(() => {
1114+
expandedGeneratedRef.current = expandedGenerated;
1115+
}, [expandedGenerated]);
1116+
11001117
useEffect(() => {
11011118
reviewCommentsRef.current = reviewComments;
11021119
}, [reviewComments]);
@@ -1601,11 +1618,13 @@ export default function App() {
16011618
: (orderedState.files[0]?.path ?? null);
16021619
const nextCollapsed =
16031620
session?.collapsed ?? getCollapsedViewedPaths(orderedState.files, nextViewed);
1621+
const nextExpandedGenerated = session?.expandedGenerated ?? new Set<string>();
16041622

16051623
stateGenerationRef.current += 1;
16061624
setState(orderedState);
16071625
setHistorySource(getHistorySource(orderedState.source) ?? historySource);
16081626
setCollapsed(new Set(nextCollapsed));
1627+
setExpandedGenerated(new Set(nextExpandedGenerated));
16091628
setItemVersionByKey({});
16101629
setReviewComments(session?.reviewComments ?? getReviewCommentsFromState(orderedState));
16111630
setReloadDeltaPaths(new Set());
@@ -1814,6 +1833,13 @@ export default function App() {
18141833
});
18151834

18161835
setCollapsed((current) => updateReviewIdentityCollapsed(current, reviewIdentity, isViewed));
1836+
if (!isViewed) {
1837+
setExpandedGenerated((current) => {
1838+
const next = new Set(current);
1839+
next.delete(reviewIdentity.key);
1840+
return next;
1841+
});
1842+
}
18171843
bumpItemVersion(reviewIdentity.key);
18181844
},
18191845
[bumpItemVersion],
@@ -2028,6 +2054,15 @@ export default function App() {
20282054
}
20292055
return next;
20302056
});
2057+
setExpandedGenerated((current) => {
2058+
const next = new Set(current);
2059+
if (isCollapsed && isGeneratedWalkthroughFile(file)) {
2060+
next.add(reviewKey);
2061+
} else {
2062+
next.delete(reviewKey);
2063+
}
2064+
return next;
2065+
});
20312066
bumpItemVersion(reviewKey);
20322067
},
20332068
[bumpItemVersion],
@@ -2542,6 +2577,7 @@ export default function App() {
25422577
commitMetadata,
25432578
diffLineHeight,
25442579
diffStyle,
2580+
expandedGenerated,
25452581
focusCommentId,
25462582
focusCommentRequest,
25472583
gitIdentity,

core/SharedWalkthroughApp.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ import {
6262
} from './lib/diff.ts';
6363
import { compactPath, fileTreeSort, fuzzyMatches, sortFiles, statusForTree } from './lib/files.ts';
6464
import { isNativeInputTarget } from './lib/keyboard.ts';
65-
import { isGeneratedWalkthroughPath } from './lib/narrative-walkthrough-diff.js';
65+
import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js';
6666
import {
6767
getCommentKey,
6868
getReviewCommentsFromState,
@@ -1051,6 +1051,7 @@ function ReviewSurface({
10511051
);
10521052
const keymap = useMemo(() => createDefaultConfig().keymap, []);
10531053
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
1054+
const [expandedGenerated, setExpandedGenerated] = useState<ReadonlySet<string>>(() => new Set());
10541055
const [fileSearchQuery, setFileSearchQuery] = useState('');
10551056
const [itemVersionByKey, setItemVersionByKey] = useState<Record<string, number>>({});
10561057
const [selectedPath, setSelectedPath] = useState<string | null>(
@@ -1140,9 +1141,7 @@ function ReviewSurface({
11401141
? selectedPath
11411142
: (visibleFiles[0]?.path ?? null);
11421143
const initialMarkdownPreviewSectionIds = useMemo(() => {
1143-
const nonGeneratedFiles = snapshot.files.filter(
1144-
(file) => !isGeneratedWalkthroughPath(file.path),
1145-
);
1144+
const nonGeneratedFiles = snapshot.files.filter((file) => !isGeneratedWalkthroughFile(file));
11461145
if (
11471146
nonGeneratedFiles.length === 0 ||
11481147
!nonGeneratedFiles.every((file) => isMarkdownFilePath(file.path))
@@ -1611,7 +1610,7 @@ function ReviewSurface({
16111610
}, []);
16121611

16131612
const toggleCollapsed = useCallback(
1614-
(_file: ChangedFile, isCollapsed: boolean, reviewKey: string) => {
1613+
(file: ChangedFile, isCollapsed: boolean, reviewKey: string) => {
16151614
setCollapsed((current) => {
16161615
const next = new Set(current);
16171616
if (isCollapsed) {
@@ -1621,6 +1620,15 @@ function ReviewSurface({
16211620
}
16221621
return next;
16231622
});
1623+
setExpandedGenerated((current) => {
1624+
const next = new Set(current);
1625+
if (isCollapsed && isGeneratedWalkthroughFile(file)) {
1626+
next.add(reviewKey);
1627+
} else {
1628+
next.delete(reviewKey);
1629+
}
1630+
return next;
1631+
});
16241632
bumpItemVersion(reviewKey);
16251633
},
16261634
[bumpItemVersion],
@@ -1629,6 +1637,13 @@ function ReviewSurface({
16291637
(_file: ChangedFile, isViewed: boolean, reviewIdentity: ReviewIdentity) => {
16301638
setViewed((current) => updateReviewIdentityViewed(current, reviewIdentity, isViewed));
16311639
setCollapsed((current) => updateReviewIdentityCollapsed(current, reviewIdentity, isViewed));
1640+
if (!isViewed) {
1641+
setExpandedGenerated((current) => {
1642+
const next = new Set(current);
1643+
next.delete(reviewIdentity.key);
1644+
return next;
1645+
});
1646+
}
16321647
bumpItemVersion(reviewIdentity.key);
16331648
},
16341649
[bumpItemVersion],
@@ -1685,6 +1700,7 @@ function ReviewSurface({
16851700
diffLineHeight,
16861701
diffStyle: snapshot.preferences.diffStyle,
16871702
disableWorkerPool: true,
1703+
expandedGenerated,
16881704
focusCommentId,
16891705
focusCommentRequest,
16901706
gitIdentity,

core/__tests__/ReviewCodeView-scroll.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,53 @@ const createLoadedMarkdownFile = (contents: string, fingerprint: string) => {
125125
};
126126
};
127127

128+
test('generated files are collapsed by default and can be explicitly expanded per review', async () => {
129+
const file = createChangedFile('src/__generated__/api.ts');
130+
const reviewKey = 'walkthrough:generated-api';
131+
const blocks: ReadonlyArray<ReviewDiffBlock> = [
132+
{
133+
file,
134+
id: reviewKey,
135+
reviewIdentity: {
136+
fingerprint: file.fingerprint,
137+
key: reviewKey,
138+
},
139+
},
140+
];
141+
const view = await renderReact(<ReviewCodeViewHarness blocks={blocks} files={[]} />);
142+
143+
try {
144+
await waitFor(() => {
145+
expect(
146+
view.container.querySelector('[aria-label="Expand file"]')?.getAttribute('aria-expanded'),
147+
).toBe('false');
148+
expect(view.container.querySelector('.codiff-generated-badge')?.textContent).toBe(
149+
'Generated',
150+
);
151+
});
152+
153+
await view.rerender(
154+
<ReviewCodeViewHarness
155+
blocks={blocks}
156+
expandedGenerated={new Set([reviewKey])}
157+
files={[]}
158+
itemVersionByKey={{ [reviewKey]: 1 }}
159+
/>,
160+
);
161+
162+
await waitFor(() => {
163+
expect(
164+
view.container.querySelector('[aria-label="Collapse file"]')?.getAttribute('aria-expanded'),
165+
).toBe('true');
166+
expect(view.container.querySelector('.codiff-generated-badge')?.textContent).toBe(
167+
'Generated',
168+
);
169+
});
170+
} finally {
171+
await view.cleanup();
172+
}
173+
});
174+
128175
test('switching edited Markdown back to a diff flushes and refreshes it first', async () => {
129176
const order: Array<string> = [];
130177
const initialFile = createLoadedMarkdownFile('# Edited\n', 'plan.md:initial');

core/__tests__/SharedWalkthroughApp.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,10 @@ test('shared walkthroughs switch between walkthrough and tree review modes', asy
207207

208208
test('shared walkthroughs initially preview Markdown when other files are generated', async () => {
209209
const file = createMarkdownFile();
210-
const generatedFile = createChangedFile('src/__generated__/api.ts');
210+
const generatedFile = {
211+
...createChangedFile('src/api.ts'),
212+
generated: true,
213+
};
211214
const source = { type: 'working-tree' } as const;
212215
const walkthrough = {
213216
agent: 'codex',

0 commit comments

Comments
 (0)