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