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