1use 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}