Skip to main content

code_moniker_workspace/changes/
diff.rs

1// code-moniker: ignore-file[smell-clone-reflex]
2// Diff extraction clones paths and ranges into stable owned change records.
3use std::borrow::Cow;
4use std::collections::HashSet;
5use std::ffi::OsStr;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use code_moniker_core::core::code_graph::{CodeGraph, DefRecord};
10use code_moniker_core::core::moniker::Moniker;
11use code_moniker_core::lang::Lang;
12use rustc_hash::{FxHashMap, FxHashSet};
13
14use crate::code::{def_kind, is_navigable_def, last_name};
15use crate::environment::{self, ExtractContext};
16use crate::gitignore::GitignoreStack;
17use crate::lines::LineIndex;
18use crate::snapshot::SymbolLocation;
19use crate::source_group::DeclaredSourceGroups;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum ChangeStatus {
23	Added,
24	Modified,
25	Removed,
26}
27
28impl ChangeStatus {
29	pub fn label(self) -> &'static str {
30		match self {
31			Self::Added => "added",
32			Self::Modified => "modified",
33			Self::Removed => "removed",
34		}
35	}
36
37	pub fn marker(self) -> &'static str {
38		match self {
39			Self::Added => "+",
40			Self::Modified => "~",
41			Self::Removed => "-",
42		}
43	}
44}
45
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct GitResourceStatus {
48	pub label: String,
49	pub git_root: Option<PathBuf>,
50	pub message: String,
51}
52
53impl GitResourceStatus {
54	pub fn available(&self) -> bool {
55		self.git_root.is_some()
56	}
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct GitRevision {
61	pub branch: String,
62	pub commit: String,
63	pub dirty: bool,
64}
65
66pub fn git_revision(path: &Path) -> Result<GitRevision, String> {
67	let worktree = GitWorktree::discover(path)?;
68	let commit = git_cli_text(worktree.root(), &["rev-parse", "HEAD"])?
69		.trim()
70		.to_string();
71	let branch = git_cli_text(worktree.root(), &["branch", "--show-current"])?
72		.trim()
73		.to_string();
74	let status = git_cli_text(
75		worktree.root(),
76		&["status", "--porcelain=v1", "--untracked-files=normal"],
77	)?;
78	Ok(GitRevision {
79		branch: if branch.is_empty() {
80			"detached".to_string()
81		} else {
82			branch
83		},
84		commit,
85		dirty: !status.trim().is_empty(),
86	})
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct ChangeEntry {
91	pub loc: Option<SymbolLocation>,
92	pub status: ChangeStatus,
93	pub lang: Lang,
94	pub file_path: PathBuf,
95	pub kind: String,
96	pub name: String,
97	pub moniker: Moniker,
98	pub hunk_count: usize,
99	pub line_range: Option<(u32, u32)>,
100}
101
102pub struct ChangeRoot<'a> {
103	pub label: &'a str,
104	pub path: &'a Path,
105	pub ctx: &'a ExtractContext,
106	pub source_groups: &'a DeclaredSourceGroups,
107}
108
109pub struct ChangeFile<'a> {
110	pub file_idx: usize,
111	pub source_root: usize,
112	pub path: &'a Path,
113	pub rel_path: &'a Path,
114	pub anchor: &'a Path,
115	pub lang: Lang,
116	pub srcset: Option<&'a str>,
117	pub graph: &'a CodeGraph,
118	pub source: &'a str,
119}
120
121pub struct ChangeScan<'a> {
122	pub roots: Vec<ChangeRoot<'a>>,
123	pub files: Vec<ChangeFile<'a>>,
124}
125
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub struct ChangeIndex {
128	pub scope: String,
129	pub entries: Vec<ChangeEntry>,
130	pub resources: Vec<GitResourceStatus>,
131	pub diagnostics: Vec<String>,
132	entries_by_symbol: FxHashMap<SymbolLocation, usize>,
133	count_by_file: FxHashMap<usize, usize>,
134}
135
136impl Default for ChangeIndex {
137	fn default() -> Self {
138		Self {
139			scope: "HEAD..worktree".to_string(),
140			entries: Vec::new(),
141			resources: Vec::new(),
142			diagnostics: Vec::new(),
143			entries_by_symbol: FxHashMap::default(),
144			count_by_file: FxHashMap::default(),
145		}
146	}
147}
148
149impl ChangeIndex {
150	pub fn entry_for(&self, loc: &SymbolLocation) -> Option<&ChangeEntry> {
151		self.entries_by_symbol
152			.get(loc)
153			.and_then(|idx| self.entries.get(*idx))
154	}
155
156	pub fn changed_symbols(&self) -> Vec<SymbolLocation> {
157		self.entries.iter().filter_map(|entry| entry.loc).collect()
158	}
159
160	pub fn change_count_for_file(&self, file_idx: usize) -> usize {
161		self.count_by_file.get(&file_idx).copied().unwrap_or(0)
162	}
163
164	pub fn changed_file_count(&self) -> usize {
165		self.entries
166			.iter()
167			.map(|entry| entry.file_path.clone())
168			.collect::<HashSet<_>>()
169			.len()
170	}
171
172	fn rebuild_lookups(&mut self) {
173		self.entries_by_symbol.clear();
174		self.count_by_file.clear();
175		for (idx, entry) in self.entries.iter().enumerate() {
176			let Some(loc) = entry.loc else {
177				continue;
178			};
179			self.entries_by_symbol.insert(loc, idx);
180			*self.count_by_file.entry(loc.file).or_default() += 1;
181		}
182	}
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct DiffScope {
187	pub base: BaseRev,
188	pub head: HeadSide,
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub enum BaseRev {
193	Rev(String),
194	MergeBase(String, String),
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub enum HeadSide {
199	Worktree,
200	Rev(String),
201}
202
203impl DiffScope {
204	pub fn worktree() -> Self {
205		Self {
206			base: BaseRev::Rev("HEAD".to_string()),
207			head: HeadSide::Worktree,
208		}
209	}
210
211	pub fn parse_range(range: &str) -> Result<Self, String> {
212		let (base, head, merge) = match range.split_once("...") {
213			Some((base, head)) => (base, head, true),
214			None => match range.split_once("..") {
215				Some((base, head)) => (base, head, false),
216				None => return Err(format!("`{range}` is not a <base>..<head> range")),
217			},
218		};
219		if base.is_empty() || head.is_empty() {
220			return Err(format!("`{range}` is missing a base or head revision"));
221		}
222		let base_rev = if merge {
223			BaseRev::MergeBase(base.to_string(), head.to_string())
224		} else {
225			BaseRev::Rev(base.to_string())
226		};
227		Ok(Self {
228			base: base_rev,
229			head: HeadSide::Rev(head.to_string()),
230		})
231	}
232
233	pub fn label(&self) -> String {
234		let base = match &self.base {
235			BaseRev::Rev(rev) => rev.clone(),
236			BaseRev::MergeBase(a, b) => format!("merge-base({a},{b})"),
237		};
238		match &self.head {
239			HeadSide::Worktree => format!("{base}..worktree"),
240			HeadSide::Rev(rev) => format!("{base}..{rev}"),
241		}
242	}
243}
244
245pub(in crate::changes) fn resolve_base_rev(
246	git_root: &Path,
247	base: &BaseRev,
248) -> Result<String, String> {
249	match base {
250		BaseRev::Rev(rev) => git_cli_text(git_root, &["rev-parse", "--verify", "--quiet", rev])
251			.map(|_| rev.clone())
252			.map_err(|_| format!("cannot resolve revision `{rev}`")),
253		BaseRev::MergeBase(a, b) => git_cli_text(git_root, &["merge-base", a, b])
254			.map(|out| out.trim().to_string())
255			.map_err(|error| format!("cannot resolve merge-base of `{a}` and `{b}`: {error}")),
256	}
257}
258
259#[derive(Clone, Debug)]
260pub(in crate::changes) struct FileDiff {
261	pub(in crate::changes) repo_root: PathBuf,
262	pub(in crate::changes) repo_rel: PathBuf,
263	pub(in crate::changes) origin: Option<RenameOrigin>,
264	pub(in crate::changes) status: FileDiffStatus,
265	pub(in crate::changes) hunks: Vec<DiffHunk>,
266}
267
268#[derive(Clone, Debug, Eq, PartialEq)]
269pub(in crate::changes) struct RenameOrigin {
270	pub(in crate::changes) repo_rel: PathBuf,
271	pub(in crate::changes) score: u8,
272}
273
274#[derive(Clone, Copy, Debug, Eq, PartialEq)]
275pub(in crate::changes) enum FileDiffStatus {
276	Tracked,
277	Added,
278	Deleted,
279	Renamed,
280}
281
282#[derive(Clone, Copy, Debug, Eq, PartialEq)]
283pub(in crate::changes) struct DiffHunk {
284	pub(in crate::changes) old: Option<LineSpan>,
285	pub(in crate::changes) new: Option<LineSpan>,
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq)]
289pub(in crate::changes) struct LineSpan {
290	pub(in crate::changes) start: u32,
291	pub(in crate::changes) end: u32,
292}
293
294impl LineSpan {
295	fn intersects(self, other: Self) -> bool {
296		self.start <= other.end && other.start <= self.end
297	}
298}
299
300pub fn build_change_index(scan: ChangeScan<'_>) -> ChangeIndex {
301	let mut changes = ChangeIndex::default();
302	let mut diffs = Vec::new();
303	for root in &scan.roots {
304		match GitWorktree::discover(root.path) {
305			Ok(repo) => {
306				let git_root = repo.root().to_path_buf();
307				changes.resources.push(GitResourceStatus {
308					label: root.label.to_string(),
309					git_root: Some(git_root.clone()),
310					message: format!("git root {}", git_root.display()),
311				});
312				match collect_changed_files(&git_root, root.path, "HEAD", &HeadSide::Worktree) {
313					Ok(mut root_diffs) => diffs.append(&mut root_diffs),
314					Err(error) => changes.diagnostics.push(format!(
315						"{}: cannot inspect git changes: {error}",
316						root.label
317					)),
318				}
319			}
320			Err(message) => {
321				changes.resources.push(GitResourceStatus {
322					label: root.label.to_string(),
323					git_root: None,
324					message: message.clone(),
325				});
326				changes.diagnostics.push(message);
327			}
328		}
329	}
330	let mut entries = Vec::new();
331	let relevant_diffs = scan.relevant_diffs(&diffs);
332	for file in &scan.files {
333		let Some(diff) = relevant_diffs.for_file(file.path) else {
334			continue;
335		};
336		entries.extend(changed_entries_for_file(&scan, file, diff));
337	}
338	for diff in relevant_diffs.deleted {
339		match removed_entries_for_deleted_file(&scan, diff) {
340			Ok(mut removed) => entries.append(&mut removed),
341			Err(error) => changes.diagnostics.push(error.to_string()),
342		}
343	}
344	entries.sort_by(|a, b| {
345		a.file_path
346			.cmp(&b.file_path)
347			.then_with(|| a.moniker.cmp(&b.moniker))
348	});
349	entries.dedup_by_key(|entry| entry.moniker.clone());
350	changes.entries = entries;
351	changes.rebuild_lookups();
352	changes
353}
354
355pub(in crate::changes) struct RelevantDiffs<'a> {
356	pub(in crate::changes) by_path: FxHashMap<PathBuf, &'a FileDiff>,
357	pub(in crate::changes) deleted: Vec<&'a FileDiff>,
358}
359
360struct SourceVisibility {
361	current_paths: FxHashSet<PathBuf>,
362	deleted_roots: Vec<DeletedSourceRoot>,
363}
364
365struct DeletedSourceRoot {
366	path: PathBuf,
367	gitignore: GitignoreStack,
368}
369
370impl<'scan> ChangeScan<'scan> {
371	pub(in crate::changes) fn relevant_diffs<'diff>(
372		&self,
373		diffs: &'diff [FileDiff],
374	) -> RelevantDiffs<'diff> {
375		let visibility = self.source_visibility();
376		let mut by_path = FxHashMap::default();
377		let mut deleted = Vec::new();
378		for diff in diffs {
379			let path = normalize_path(&diff_path(diff));
380			match diff.status {
381				FileDiffStatus::Deleted => {
382					if visibility.accepts_deleted_path(&path) {
383						deleted.push(diff);
384					}
385				}
386				FileDiffStatus::Tracked | FileDiffStatus::Added | FileDiffStatus::Renamed => {
387					if visibility.current_paths.contains(&path) {
388						by_path.insert(path, diff);
389					}
390				}
391			}
392		}
393		RelevantDiffs { by_path, deleted }
394	}
395
396	fn source_visibility(&self) -> SourceVisibility {
397		SourceVisibility {
398			current_paths: self
399				.files
400				.iter()
401				.map(|file| normalize_path(file.path))
402				.collect(),
403			deleted_roots: self
404				.roots
405				.iter()
406				.map(|root| DeletedSourceRoot::new(root.path))
407				.collect(),
408		}
409	}
410}
411
412impl SourceVisibility {
413	fn accepts_deleted_path(&self, path: &Path) -> bool {
414		environment::language_for_path(path).is_ok()
415			&& self.deleted_roots.iter().any(|root| root.accepts(path))
416	}
417}
418
419impl DeletedSourceRoot {
420	fn new(path: &Path) -> Self {
421		let path = normalize_path(path);
422		Self {
423			gitignore: GitignoreStack::for_root(&path),
424			path,
425		}
426	}
427
428	fn accepts(&self, path: &Path) -> bool {
429		let Ok(rel) = path.strip_prefix(&self.path) else {
430			return false;
431		};
432		!has_hidden_component(rel) && !self.gitignore.is_ignored(path, false)
433	}
434}
435
436impl<'a> RelevantDiffs<'a> {
437	fn for_file(&self, path: &Path) -> Option<&'a FileDiff> {
438		self.by_path.get(&normalize_path(path)).copied()
439	}
440}
441
442fn has_hidden_component(path: &Path) -> bool {
443	path.components().any(|component| {
444		let name = component.as_os_str();
445		name != OsStr::new(".")
446			&& name != OsStr::new("..")
447			&& name.as_encoded_bytes().first() == Some(&b'.')
448	})
449}
450
451fn changed_entries_for_file(
452	scan: &ChangeScan<'_>,
453	file: &ChangeFile<'_>,
454	diff: &FileDiff,
455) -> Vec<ChangeEntry> {
456	let lines = LineIndex::new(file.source);
457	let base = if diff.status == FileDiffStatus::Added {
458		BaseFile::default()
459	} else {
460		base_file(scan, file, diff).unwrap_or_default()
461	};
462	let base_monikers: HashSet<_> = base.defs.iter().map(|def| def.moniker.clone()).collect();
463	let current_monikers: HashSet<_> = file
464		.graph
465		.defs()
466		.filter(|def| is_navigable_def(file.lang, def))
467		.map(|def| def.moniker.clone())
468		.collect();
469	let candidates: Vec<_> = file
470		.graph
471		.defs()
472		.enumerate()
473		.filter_map(|(def_idx, def)| {
474			if !is_navigable_def(file.lang, def) {
475				return None;
476			}
477			let status = if base_monikers.contains(&def.moniker) {
478				ChangeStatus::Modified
479			} else {
480				ChangeStatus::Added
481			};
482			if status == ChangeStatus::Modified && !def_intersects_hunks(def, &lines, diff) {
483				return None;
484			}
485			Some(SymbolLocation {
486				file: file.file_idx,
487				symbol: def_idx,
488			})
489		})
490		.collect();
491	let keep_ancestors = matches!(diff.status, FileDiffStatus::Added | FileDiffStatus::Renamed);
492	let mut entries: Vec<_> = candidates
493		.iter()
494		.copied()
495		.filter(|loc| {
496			keep_ancestors
497				|| !candidates.iter().any(|candidate| {
498					candidate != loc && is_descendant(file.graph, loc.symbol, candidate.symbol)
499				})
500		})
501		.map(|loc| {
502			let def = file.graph.def_at(loc.symbol);
503			let status = if base_monikers.contains(&def.moniker) {
504				ChangeStatus::Modified
505			} else {
506				ChangeStatus::Added
507			};
508			ChangeEntry {
509				loc: Some(loc),
510				status,
511				lang: file.lang,
512				file_path: file.rel_path.to_path_buf(),
513				kind: def_kind(def),
514				name: last_name(&def.moniker),
515				moniker: def.moniker.clone(),
516				hunk_count: diff.hunks.len(),
517				line_range: def
518					.position
519					.map(|(start, end)| lines.line_range(start, end)),
520			}
521		})
522		.collect();
523	let base_removals_delegated_to_synthetic_delete = diff.status == FileDiffStatus::Renamed;
524	entries.extend(
525		base.defs
526			.iter()
527			.filter(|_| !base_removals_delegated_to_synthetic_delete)
528			.filter(|def| !current_monikers.contains(&def.moniker))
529			.filter(|def| old_span_intersects_hunks(def.line_range, diff))
530			.map(|def| ChangeEntry {
531				loc: None,
532				status: ChangeStatus::Removed,
533				lang: file.lang,
534				file_path: file.rel_path.to_path_buf(),
535				kind: def.kind.clone(),
536				name: def.name.clone(),
537				moniker: def.moniker.clone(),
538				hunk_count: diff.hunks.len(),
539				line_range: Some(def.line_range),
540			}),
541	);
542	entries
543}
544
545fn def_intersects_hunks(def: &DefRecord, lines: &LineIndex, diff: &FileDiff) -> bool {
546	let Some((start, end)) = def.position else {
547		return false;
548	};
549	let (start_line, end_line) = lines.line_range(start, end);
550	let def_span = LineSpan {
551		start: start_line,
552		end: end_line,
553	};
554	diff.hunks
555		.iter()
556		.filter_map(|hunk| hunk.new)
557		.any(|hunk| def_span.intersects(hunk))
558}
559
560fn old_span_intersects_hunks(line_range: (u32, u32), diff: &FileDiff) -> bool {
561	let def_span = LineSpan {
562		start: line_range.0,
563		end: line_range.1,
564	};
565	diff.hunks
566		.iter()
567		.filter_map(|hunk| hunk.old)
568		.any(|hunk| def_span.intersects(hunk))
569}
570
571fn is_descendant(graph: &CodeGraph, ancestor: usize, mut child: usize) -> bool {
572	while let Some(parent) = graph.def_at(child).parent {
573		if parent == ancestor {
574			return true;
575		}
576		child = parent;
577	}
578	false
579}
580
581#[derive(Clone, Debug, Default)]
582struct BaseFile {
583	defs: Vec<BaseDef>,
584}
585
586#[derive(Clone, Debug)]
587struct BaseDef {
588	moniker: Moniker,
589	kind: String,
590	name: String,
591	line_range: (u32, u32),
592}
593
594fn base_file(
595	scan: &ChangeScan<'_>,
596	file: &ChangeFile<'_>,
597	diff: &FileDiff,
598) -> anyhow::Result<BaseFile> {
599	let (blob_rel, anchor) = base_blob_location(scan, file, diff)?;
600	let source = git_show(&diff.repo_root, "HEAD", &blob_rel)?;
601	let ctx = match &diff.origin {
602		Some(origin) => {
603			let old_path = diff.repo_root.join(&origin.repo_rel);
604			let Some((_, root, _)) = source_root_for_path(scan, &old_path) else {
605				anyhow::bail!(
606					"rename origin {} is outside the scanned source roots",
607					origin.repo_rel.display()
608				);
609			};
610			extraction_context_for_path(root, &old_path)
611		}
612		None => extraction_context_for_file(scan, file),
613	};
614	let graph = environment::extract_source_with(file.lang, &source, &anchor, &ctx);
615	let lines = LineIndex::new(&source);
616	Ok(BaseFile {
617		defs: graph
618			.defs()
619			.filter(|def| is_navigable_def(file.lang, def))
620			.filter_map(|def| {
621				let (start, end) = def.position?;
622				Some(BaseDef {
623					moniker: def.moniker.clone(),
624					kind: def_kind(def),
625					name: last_name(&def.moniker),
626					line_range: lines.line_range(start, end),
627				})
628			})
629			.collect(),
630	})
631}
632
633fn base_blob_location(
634	scan: &ChangeScan<'_>,
635	file: &ChangeFile<'_>,
636	diff: &FileDiff,
637) -> anyhow::Result<(PathBuf, PathBuf)> {
638	let Some(origin) = &diff.origin else {
639		return Ok((diff.repo_rel.clone(), file.anchor.to_path_buf()));
640	};
641	let old_path = diff.repo_root.join(&origin.repo_rel);
642	let Some((_, root, rel_path)) = source_root_for_path(scan, &old_path) else {
643		anyhow::bail!(
644			"rename origin {} is outside the scanned source roots",
645			origin.repo_rel.display()
646		);
647	};
648	Ok((origin.repo_rel.clone(), anchor_for(scan, root, &rel_path)))
649}
650
651fn removed_entries_for_deleted_file(
652	scan: &ChangeScan<'_>,
653	diff: &FileDiff,
654) -> anyhow::Result<Vec<ChangeEntry>> {
655	let source = git_show(&diff.repo_root, "HEAD", &diff.repo_rel)?;
656	let path = diff_path(diff);
657	let Some((source_root, root, rel_path)) = source_root_for_path(scan, &path) else {
658		return Ok(Vec::new());
659	};
660	let lang = environment::language_for_path(&path)?;
661	let anchor = anchor_for(scan, root, &rel_path);
662	let ctx = extraction_context_for_path(root, &path);
663	let graph = environment::extract_source_with(lang, &source, &anchor, &ctx);
664	let lines = LineIndex::new(&source);
665	let mut entries = Vec::new();
666	for def in graph.defs().filter(|def| is_navigable_def(lang, def)) {
667		let Some((start, end)) = def.position else {
668			continue;
669		};
670		let range = lines.line_range(start, end);
671		entries.push(ChangeEntry {
672			loc: None,
673			status: ChangeStatus::Removed,
674			lang,
675			file_path: display_rel_path(scan, source_root, root, &rel_path),
676			kind: def_kind(def),
677			name: last_name(&def.moniker),
678			moniker: def.moniker.clone(),
679			hunk_count: diff.hunks.len(),
680			line_range: Some(range),
681		});
682	}
683	Ok(entries)
684}
685
686pub(in crate::changes) fn extraction_context_for_file<'a>(
687	scan: &'a ChangeScan<'a>,
688	file: &'a ChangeFile<'a>,
689) -> Cow<'a, ExtractContext> {
690	let root = &scan.roots[file.source_root];
691	crate::sources::extraction_context_with_srcset(root.ctx, file.srcset)
692}
693
694pub(in crate::changes) fn extraction_context_for_path<'a>(
695	root: &'a ChangeRoot<'a>,
696	path: &Path,
697) -> Cow<'a, ExtractContext> {
698	let srcset = root
699		.source_groups
700		.membership(path)
701		.and_then(|membership| membership.srcset);
702	crate::sources::extraction_context_with_srcset(root.ctx, srcset)
703}
704
705pub(in crate::changes) fn source_root_for_path<'a>(
706	scan: &'a ChangeScan<'_>,
707	path: &Path,
708) -> Option<(usize, &'a ChangeRoot<'a>, PathBuf)> {
709	let path = normalize_path(path);
710	scan.roots.iter().enumerate().find_map(|(idx, root)| {
711		let root_path = normalize_path(root.path);
712		path.strip_prefix(&root_path)
713			.ok()
714			.map(|rel| (idx, root, rel.to_path_buf()))
715	})
716}
717
718pub(in crate::changes) fn anchor_for(
719	scan: &ChangeScan<'_>,
720	root: &ChangeRoot<'_>,
721	rel_path: &Path,
722) -> PathBuf {
723	if scan.roots.len() > 1 {
724		PathBuf::from(root.label).join(rel_path)
725	} else {
726		rel_path.to_path_buf()
727	}
728}
729
730pub(in crate::changes) fn display_rel_path(
731	scan: &ChangeScan<'_>,
732	source_root: usize,
733	root: &ChangeRoot<'_>,
734	rel_path: &Path,
735) -> PathBuf {
736	if scan.roots.len() > 1 {
737		PathBuf::from(root.label).join(rel_path)
738	} else {
739		let _ = source_root;
740		rel_path.to_path_buf()
741	}
742}
743
744pub(in crate::changes) fn collect_changed_files(
745	git_root: &Path,
746	source_root: &Path,
747	base_rev: &str,
748	head: &HeadSide,
749) -> Result<Vec<FileDiff>, String> {
750	let pathspec = git_pathspec(git_root, source_root);
751	let mut name_status_args = vec![
752		"diff",
753		"--name-status",
754		"--find-renames",
755		"--diff-filter=ACMRD",
756		base_rev,
757	];
758	if let HeadSide::Rev(head_rev) = head {
759		name_status_args.push(head_rev);
760	}
761	name_status_args.extend(["--", &pathspec]);
762	let mut out = Vec::new();
763	for row in git_cli_lines(git_root, &name_status_args)? {
764		let (status, repo_rel, origin) = parse_name_status(&row)?;
765		let scope_refs = HunkScope {
766			base_rev,
767			head,
768			repo_rel: &repo_rel,
769			origin: origin.as_ref(),
770		};
771		let hunks = parse_diff_hunks(&hunk_diff_text(git_root, &scope_refs)?);
772		if let Some(origin) = &origin {
773			out.push(FileDiff {
774				repo_root: git_root.to_path_buf(),
775				repo_rel: origin.repo_rel.clone(),
776				origin: None,
777				status: FileDiffStatus::Deleted,
778				hunks: Vec::new(),
779			});
780		}
781		out.push(FileDiff {
782			repo_root: git_root.to_path_buf(),
783			repo_rel,
784			origin,
785			status,
786			hunks,
787		});
788	}
789	if *head == HeadSide::Worktree {
790		for rel in git_cli_lines(
791			git_root,
792			&[
793				"ls-files",
794				"--others",
795				"--exclude-standard",
796				"--",
797				&pathspec,
798			],
799		)? {
800			out.push(FileDiff {
801				repo_root: git_root.to_path_buf(),
802				repo_rel: PathBuf::from(rel),
803				origin: None,
804				status: FileDiffStatus::Added,
805				hunks: Vec::new(),
806			});
807		}
808	}
809	Ok(out)
810}
811
812struct HunkScope<'a> {
813	base_rev: &'a str,
814	head: &'a HeadSide,
815	repo_rel: &'a Path,
816	origin: Option<&'a RenameOrigin>,
817}
818
819fn hunk_diff_text(git_root: &Path, scope: &HunkScope<'_>) -> Result<String, String> {
820	let new_path = path_to_git(scope.repo_rel);
821	let mut args = vec!["diff", "--unified=0"];
822	if scope.origin.is_some() {
823		args.push("--find-renames");
824	}
825	args.push(scope.base_rev);
826	if let HeadSide::Rev(head_rev) = scope.head {
827		args.push(head_rev);
828	}
829	args.push("--");
830	let old_path = scope.origin.map(|origin| path_to_git(&origin.repo_rel));
831	if let Some(old_path) = &old_path {
832		args.push(old_path);
833	}
834	args.push(&new_path);
835	git_cli_text(git_root, &args)
836}
837
838type ParsedNameStatus = (FileDiffStatus, PathBuf, Option<RenameOrigin>);
839
840fn parse_name_status(row: &str) -> Result<ParsedNameStatus, String> {
841	let parts: Vec<&str> = row.split('\t').collect();
842	let Some(raw_status) = parts.first().copied().filter(|part| !part.is_empty()) else {
843		return Err(format!("cannot parse git name-status row {row:?}"));
844	};
845	let malformed = || format!("cannot parse git name-status row {row:?}");
846	if let Some(raw_score) = raw_status.strip_prefix('R') {
847		let old = parts.get(1).copied().ok_or_else(malformed)?;
848		let new = parts.get(2).copied().ok_or_else(malformed)?;
849		let score = raw_score.parse::<u8>().unwrap_or(0);
850		return Ok((
851			FileDiffStatus::Renamed,
852			PathBuf::from(new),
853			Some(RenameOrigin {
854				repo_rel: PathBuf::from(old),
855				score,
856			}),
857		));
858	}
859	let path = parts.get(1).copied().ok_or_else(malformed)?;
860	let status = match raw_status.chars().next() {
861		Some('A') => FileDiffStatus::Added,
862		Some('D') => FileDiffStatus::Deleted,
863		_ => FileDiffStatus::Tracked,
864	};
865	Ok((status, PathBuf::from(path), None))
866}
867
868pub(in crate::changes) struct GitWorktree {
869	root: PathBuf,
870}
871
872impl GitWorktree {
873	pub(in crate::changes) fn discover(path: &Path) -> Result<Self, String> {
874		let output = git_cli_command(path)
875			.args(["rev-parse", "--show-toplevel"])
876			.output()
877			.map_err(|e| format!("cannot run git rev-parse in {}: {e}", path.display()))?;
878		if !output.status.success() {
879			return Err(format!("{} is not inside a Git repository", path.display()));
880		}
881		let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
882		if root.is_empty() {
883			return Err(format!("{} is not inside a Git worktree", path.display()));
884		}
885		Ok(Self {
886			root: normalize_path(Path::new(&root)),
887		})
888	}
889
890	pub(in crate::changes) fn root(&self) -> &Path {
891		&self.root
892	}
893}
894
895fn git_cli_lines(git_root: &Path, args: &[&str]) -> Result<Vec<String>, String> {
896	Ok(git_cli_text(git_root, args)?
897		.lines()
898		.map(str::trim)
899		.filter(|line| !line.is_empty())
900		.map(ToOwned::to_owned)
901		.collect())
902}
903
904pub(in crate::changes) fn git_show(
905	git_root: &Path,
906	rev: &str,
907	repo_rel: &Path,
908) -> anyhow::Result<String> {
909	git_cli_text(
910		git_root,
911		&["show", &format!("{rev}:{}", path_to_git(repo_rel))],
912	)
913	.map_err(anyhow::Error::msg)
914}
915
916fn git_cli_text(git_root: &Path, args: &[&str]) -> Result<String, String> {
917	let output = git_cli_command(git_root)
918		.args(args)
919		.output()
920		.map_err(|e| format!("cannot run git {:?}: {e}", args))?;
921	if !output.status.success() {
922		return Err(format!(
923			"git {:?} failed: {}",
924			args,
925			String::from_utf8_lossy(&output.stderr).trim()
926		));
927	}
928	Ok(String::from_utf8_lossy(&output.stdout).to_string())
929}
930
931fn git_cli_command(cwd: &Path) -> Command {
932	let mut command = Command::new("git");
933	command.env("GIT_OPTIONAL_LOCKS", "0").arg("-C").arg(cwd);
934	command
935}
936
937fn git_pathspec(git_root: &Path, source_root: &Path) -> String {
938	let root = normalize_path(git_root);
939	let source = normalize_path(source_root);
940	let rel = source.strip_prefix(&root).unwrap_or(source.as_path());
941	if rel.as_os_str().is_empty() {
942		".".to_string()
943	} else {
944		path_to_git(rel)
945	}
946}
947
948pub(in crate::changes) fn diff_path(diff: &FileDiff) -> PathBuf {
949	diff.repo_root.join(&diff.repo_rel)
950}
951
952pub(in crate::changes) fn normalize_path(path: &Path) -> PathBuf {
953	path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
954}
955
956fn path_to_git(path: &Path) -> String {
957	path.components()
958		.filter_map(|component| component.as_os_str().to_str())
959		.collect::<Vec<_>>()
960		.join("/")
961}
962
963fn parse_diff_hunks(diff: &str) -> Vec<DiffHunk> {
964	diff.lines()
965		.filter_map(|line| line.strip_prefix("@@ "))
966		.filter_map(parse_hunk_header)
967		.collect()
968}
969
970fn parse_hunk_header(header: &str) -> Option<DiffHunk> {
971	let mut parts = header.split_whitespace();
972	let old = parse_hunk_side(parts.next()?)?;
973	let new = parse_hunk_side(parts.next()?)?;
974	Some(DiffHunk { old, new })
975}
976
977fn parse_hunk_side(raw: &str) -> Option<Option<LineSpan>> {
978	let raw = raw.strip_prefix(['-', '+'])?;
979	let (start, count) = raw
980		.split_once(',')
981		.map(|(start, count)| Some((start.parse::<u32>().ok()?, count.parse::<u32>().ok()?)))
982		.unwrap_or_else(|| Some((raw.parse::<u32>().ok()?, 1)))?;
983	if count == 0 {
984		return Some(None);
985	}
986	Some(Some(LineSpan {
987		start,
988		end: start + count - 1,
989	}))
990}
991
992#[cfg(test)]
993mod tests {
994	use super::*;
995
996	fn no_source_groups() -> &'static DeclaredSourceGroups {
997		static GROUPS: std::sync::OnceLock<DeclaredSourceGroups> = std::sync::OnceLock::new();
998		GROUPS.get_or_init(DeclaredSourceGroups::default)
999	}
1000
1001	fn write(root: &Path, rel: &str, body: &str) {
1002		let path = root.join(rel);
1003		if let Some(parent) = path.parent() {
1004			std::fs::create_dir_all(parent).unwrap();
1005		}
1006		std::fs::write(path, body).unwrap();
1007	}
1008
1009	fn git(root: &Path, args: &[&str]) {
1010		let output = Command::new("git")
1011			.arg("-C")
1012			.arg(root)
1013			.args(args)
1014			.output()
1015			.unwrap_or_else(|e| panic!("cannot run git {args:?}: {e}"));
1016		assert!(
1017			output.status.success(),
1018			"git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
1019			String::from_utf8_lossy(&output.stdout),
1020			String::from_utf8_lossy(&output.stderr)
1021		);
1022	}
1023
1024	fn committed_repo() -> tempfile::TempDir {
1025		let tmp = tempfile::tempdir().unwrap();
1026		init_git(tmp.path());
1027		write(tmp.path(), "src/Foo.java", "class Foo {}\n");
1028		git(tmp.path(), &["add", "."]);
1029		git(tmp.path(), &["commit", "-m", "initial"]);
1030		tmp
1031	}
1032
1033	fn init_git(root: &Path) {
1034		git(root, &["init"]);
1035		git(root, &["config", "user.email", "code-moniker@example.test"]);
1036		git(root, &["config", "user.name", "Code Moniker"]);
1037	}
1038
1039	fn rust_file<'a>(
1040		file_idx: usize,
1041		source_root: usize,
1042		path: &'a Path,
1043		rel: &'a str,
1044		source: &'a str,
1045		graph: &'a CodeGraph,
1046	) -> ChangeFile<'a> {
1047		ChangeFile {
1048			file_idx,
1049			source_root,
1050			path,
1051			rel_path: Path::new(rel),
1052			anchor: Path::new(rel),
1053			lang: Lang::Rs,
1054			srcset: None,
1055			graph,
1056			source,
1057		}
1058	}
1059
1060	fn rust_scan<'a>(
1061		root: &'a Path,
1062		ctx: &'a ExtractContext,
1063		path: &'a Path,
1064		rel: &'a str,
1065		source: &'a str,
1066		graph: &'a CodeGraph,
1067	) -> ChangeScan<'a> {
1068		ChangeScan {
1069			roots: vec![ChangeRoot {
1070				label: "repo",
1071				path: root,
1072				ctx,
1073				source_groups: no_source_groups(),
1074			}],
1075			files: vec![rust_file(0, 0, path, rel, source, graph)],
1076		}
1077	}
1078
1079	#[test]
1080	fn git_discovers_worktree_root() {
1081		let tmp = committed_repo();
1082		let nested = tmp.path().join("src");
1083
1084		let repo = GitWorktree::discover(&nested).unwrap();
1085
1086		assert_eq!(repo.root(), normalize_path(tmp.path()).as_path());
1087	}
1088
1089	#[test]
1090	fn git_revision_reports_branch_commit_and_dirty_state() {
1091		let tmp = committed_repo();
1092		let clean = git_revision(tmp.path()).unwrap();
1093		assert!(!clean.branch.is_empty());
1094		assert_eq!(clean.commit.len(), 40);
1095		assert!(!clean.dirty);
1096
1097		write(tmp.path(), "src/Foo.java", "class Foo { int changed; }\n");
1098		assert!(git_revision(tmp.path()).unwrap().dirty);
1099	}
1100
1101	#[test]
1102	fn git_reads_blob_from_head_not_worktree() {
1103		let tmp = committed_repo();
1104		write(tmp.path(), "src/Foo.java", "class Foo { int changed; }\n");
1105
1106		let text = git_show(tmp.path(), "HEAD", Path::new("src/Foo.java")).unwrap();
1107
1108		assert_eq!(text, "class Foo {}\n");
1109	}
1110
1111	#[test]
1112	fn build_change_index_reports_modified_symbols() {
1113		let tmp = tempfile::tempdir().unwrap();
1114		init_git(tmp.path());
1115		write(tmp.path(), "src/lib.rs", "fn kept() {}\nfn changed() {}\n");
1116		git(tmp.path(), &["add", "."]);
1117		git(tmp.path(), &["commit", "-m", "initial"]);
1118		let source = "fn kept() {}\nfn changed() { kept(); }\n";
1119		write(tmp.path(), "src/lib.rs", source);
1120		let path = tmp.path().join("src/lib.rs");
1121		let ctx = ExtractContext::default();
1122		let graph = environment::extract_source(Lang::Rs, source, Path::new("src/lib.rs"));
1123
1124		let index = build_change_index(rust_scan(
1125			tmp.path(),
1126			&ctx,
1127			&path,
1128			"src/lib.rs",
1129			source,
1130			&graph,
1131		));
1132
1133		assert!(index.entries.iter().any(
1134			|entry| entry.status == ChangeStatus::Modified && entry.name.starts_with("changed")
1135		));
1136	}
1137
1138	#[test]
1139	fn build_change_index_reports_untracked_source_files_as_added() {
1140		let tmp = committed_repo();
1141		let source = "fn added() {}\n";
1142		write(tmp.path(), "src/new.rs", source);
1143		let path = tmp.path().join("src/new.rs");
1144		let ctx = ExtractContext::default();
1145		let graph = environment::extract_source(Lang::Rs, source, Path::new("src/new.rs"));
1146
1147		let index = build_change_index(rust_scan(
1148			tmp.path(),
1149			&ctx,
1150			&path,
1151			"src/new.rs",
1152			source,
1153			&graph,
1154		));
1155
1156		assert!(
1157			index
1158				.entries
1159				.iter()
1160				.any(|entry| entry.status == ChangeStatus::Added && entry.name.starts_with("added"))
1161		);
1162	}
1163
1164	#[test]
1165	fn build_change_index_ignores_untracked_sources_absent_from_catalog() {
1166		let tmp = committed_repo();
1167		write(tmp.path(), ".code-moniker/generated.rs", "fn cached() {}\n");
1168		let ctx = ExtractContext::default();
1169		let scan = ChangeScan {
1170			roots: vec![ChangeRoot {
1171				label: "repo",
1172				path: tmp.path(),
1173				ctx: &ctx,
1174				source_groups: no_source_groups(),
1175			}],
1176			files: Vec::new(),
1177		};
1178
1179		let index = build_change_index(scan);
1180
1181		assert!(
1182			index.entries.is_empty(),
1183			"unexpected changes: {:?}",
1184			index.entries
1185		);
1186	}
1187
1188	#[test]
1189	fn build_change_index_limits_diffs_to_the_changed_source_root() {
1190		let tmp = tempfile::tempdir().unwrap();
1191		init_git(tmp.path());
1192		write(tmp.path(), "a/src/lib.rs", "fn changed() {}\n");
1193		write(tmp.path(), "b/src/lib.rs", "fn unchanged() {}\n");
1194		git(tmp.path(), &["add", "."]);
1195		git(tmp.path(), &["commit", "-m", "initial"]);
1196		let source_a = "fn changed() { changed(); }\n";
1197		let source_b = "fn unchanged() {}\n";
1198		write(tmp.path(), "a/src/lib.rs", source_a);
1199		let path_a = tmp.path().join("a/src/lib.rs");
1200		let path_b = tmp.path().join("b/src/lib.rs");
1201		let root_a = tmp.path().join("a");
1202		let root_b = tmp.path().join("b");
1203		let ctx = ExtractContext::default();
1204		let graph_a = environment::extract_source(Lang::Rs, source_a, Path::new("src/lib.rs"));
1205		let graph_b = environment::extract_source(Lang::Rs, source_b, Path::new("src/lib.rs"));
1206		let scan = ChangeScan {
1207			roots: vec![
1208				ChangeRoot {
1209					label: "a",
1210					path: &root_a,
1211					ctx: &ctx,
1212					source_groups: no_source_groups(),
1213				},
1214				ChangeRoot {
1215					label: "b",
1216					path: &root_b,
1217					ctx: &ctx,
1218					source_groups: no_source_groups(),
1219				},
1220			],
1221			files: vec![
1222				rust_file(0, 0, &path_a, "src/lib.rs", source_a, &graph_a),
1223				rust_file(1, 1, &path_b, "src/lib.rs", source_b, &graph_b),
1224			],
1225		};
1226
1227		let index = build_change_index(scan);
1228
1229		assert!(index.entries.iter().any(
1230			|entry| entry.status == ChangeStatus::Modified && entry.name.starts_with("changed")
1231		));
1232		assert!(
1233			index
1234				.entries
1235				.iter()
1236				.all(|entry| !entry.name.starts_with("unchanged")),
1237			"unexpected changes: {:?}",
1238			index.entries
1239		);
1240	}
1241
1242	#[test]
1243	fn build_change_index_reports_renamed_file_as_added_and_removed() {
1244		let tmp = tempfile::tempdir().unwrap();
1245		init_git(tmp.path());
1246		write(tmp.path(), "src/lib.rs", "fn moved_fn() {}\n");
1247		git(tmp.path(), &["add", "."]);
1248		git(tmp.path(), &["commit", "-m", "initial"]);
1249		git(tmp.path(), &["mv", "src/lib.rs", "src/renamed.rs"]);
1250		let source = "fn moved_fn() {}\n";
1251		let path = tmp.path().join("src/renamed.rs");
1252		let ctx = ExtractContext::default();
1253		let graph = environment::extract_source(Lang::Rs, source, Path::new("src/renamed.rs"));
1254
1255		let index = build_change_index(rust_scan(
1256			tmp.path(),
1257			&ctx,
1258			&path,
1259			"src/renamed.rs",
1260			source,
1261			&graph,
1262		));
1263
1264		assert!(
1265			index.entries.iter().any(|entry| {
1266				entry.status == ChangeStatus::Added
1267					&& entry.name.starts_with("moved_fn")
1268					&& entry.file_path == Path::new("src/renamed.rs")
1269			}),
1270			"missing added entry at new path: {:?}",
1271			index.entries
1272		);
1273		assert!(
1274			index.entries.iter().any(|entry| {
1275				entry.status == ChangeStatus::Removed
1276					&& entry.name.starts_with("moved_fn")
1277					&& entry.file_path == Path::new("src/lib.rs")
1278			}),
1279			"missing removed entry at old path: {:?}",
1280			index.entries
1281		);
1282	}
1283
1284	#[test]
1285	fn base_file_resolves_rename_origin_blob() {
1286		let tmp = tempfile::tempdir().unwrap();
1287		init_git(tmp.path());
1288		write(
1289			tmp.path(),
1290			"src/lib.rs",
1291			"fn moved_fn() {}\nfn dropped_fn() {}\nfn kept_one() {}\nfn kept_two() {}\n",
1292		);
1293		git(tmp.path(), &["add", "."]);
1294		git(tmp.path(), &["commit", "-m", "initial"]);
1295		git(tmp.path(), &["mv", "src/lib.rs", "src/renamed.rs"]);
1296		let source = "fn moved_fn() { let _grown = 1; }\nfn dropped_fn() {}\nfn kept_one() {}\nfn kept_two() {}\n";
1297		write(tmp.path(), "src/renamed.rs", source);
1298		let path = tmp.path().join("src/renamed.rs");
1299		let ctx = ExtractContext::default();
1300		let graph = environment::extract_source(Lang::Rs, source, Path::new("src/renamed.rs"));
1301		let scan = rust_scan(tmp.path(), &ctx, &path, "src/renamed.rs", source, &graph);
1302		let git_root = normalize_path(tmp.path());
1303
1304		let diffs =
1305			collect_changed_files(&git_root, tmp.path(), "HEAD", &HeadSide::Worktree).unwrap();
1306		let renamed = diffs
1307			.iter()
1308			.find(|diff| diff.status == FileDiffStatus::Renamed)
1309			.expect("renamed diff present");
1310		let base = base_file(&scan, &scan.files[0], renamed).unwrap();
1311
1312		assert_eq!(
1313			renamed
1314				.origin
1315				.as_ref()
1316				.map(|origin| origin.repo_rel.clone()),
1317			Some(PathBuf::from("src/lib.rs"))
1318		);
1319		assert!(
1320			diffs.iter().any(|diff| {
1321				diff.status == FileDiffStatus::Deleted && diff.repo_rel == Path::new("src/lib.rs")
1322			}),
1323			"missing synthetic deleted diff: {diffs:?}"
1324		);
1325		let names: Vec<_> = base.defs.iter().map(|def| def.name.clone()).collect();
1326		assert!(
1327			names.iter().any(|name| name.starts_with("moved_fn"))
1328				&& names.iter().any(|name| name.starts_with("dropped_fn")),
1329			"base blob defs not extracted from HEAD origin: {names:?}"
1330		);
1331	}
1332
1333	#[test]
1334	fn build_change_index_reports_removed_symbols_from_head() {
1335		let tmp = tempfile::tempdir().unwrap();
1336		init_git(tmp.path());
1337		write(tmp.path(), "src/lib.rs", "fn removed() {}\n");
1338		git(tmp.path(), &["add", "."]);
1339		git(tmp.path(), &["commit", "-m", "initial"]);
1340		std::fs::remove_file(tmp.path().join("src/lib.rs")).expect("remove source");
1341		let ctx = ExtractContext::default();
1342		let scan = ChangeScan {
1343			roots: vec![ChangeRoot {
1344				label: "repo",
1345				path: tmp.path(),
1346				ctx: &ctx,
1347				source_groups: no_source_groups(),
1348			}],
1349			files: Vec::new(),
1350		};
1351
1352		let index = build_change_index(scan);
1353
1354		assert!(index.entries.iter().any(
1355			|entry| entry.status == ChangeStatus::Removed && entry.name.starts_with("removed")
1356		));
1357	}
1358
1359	#[test]
1360	fn build_change_index_ignores_removed_sources_excluded_from_catalog() {
1361		let tmp = tempfile::tempdir().unwrap();
1362		init_git(tmp.path());
1363		write(tmp.path(), ".gitignore", "target/\n");
1364		write(tmp.path(), "target/generated.rs", "fn generated() {}\n");
1365		git(tmp.path(), &["add", ".gitignore"]);
1366		git(tmp.path(), &["add", "-f", "target/generated.rs"]);
1367		git(tmp.path(), &["commit", "-m", "initial"]);
1368		std::fs::remove_file(tmp.path().join("target/generated.rs")).expect("remove source");
1369		let ctx = ExtractContext::default();
1370		let scan = ChangeScan {
1371			roots: vec![ChangeRoot {
1372				label: "repo",
1373				path: tmp.path(),
1374				ctx: &ctx,
1375				source_groups: no_source_groups(),
1376			}],
1377			files: Vec::new(),
1378		};
1379
1380		let index = build_change_index(scan);
1381
1382		assert!(
1383			index.entries.is_empty(),
1384			"unexpected changes: {:?}",
1385			index.entries
1386		);
1387	}
1388}