Skip to content

Autoheal#32

Open
inful wants to merge 12 commits intofixingfrom
autoheal
Open

Autoheal#32
inful wants to merge 12 commits intofixingfrom
autoheal

Conversation

@inful
Copy link
Copy Markdown
Owner

@inful inful commented Jan 20, 2026

  • Refactor Fixer to use go-git for atomic transactions and rollback
  • Add healing phase to fix broken links using Git history
  • Implement content-aware move detection in fixer_healing.go
  • Refactor file operations to use Git library instead of commands
  • Add unit tests for healing and rollback functionality

inful added 2 commits January 20, 2026 15:35
- Refactor Fixer to use go-git for atomic transactions and rollback
- Add healing phase to fix broken links using Git history
- Implement content-aware move detection in fixer_healing.go
- Refactor file operations to use Git library instead of commands
- Add unit tests for healing and rollback functionality
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements an "Autoheal" feature that adds Git-based atomic transactions and self-healing capabilities to the linting system. The PR introduces automatic broken link repair by analyzing Git history to detect file renames/moves, and adds rollback functionality to safely revert changes when errors occur.

Changes:

  • Refactored Git operations from shell commands to use go-git library directly
  • Added healing phase that detects and fixes broken links using Git history analysis
  • Implemented rollback mechanism using Git hard reset to initial commit SHA
  • Added helper method AddLinkUpdate to the FixResult type

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
internal/lint/fixer_healing.go New module implementing link healing via Git history analysis and content-aware move detection
internal/lint/fixer_healing_test.go Basic integration test for healing broken links after external file renames
internal/lint/fixer_rollback_test.go Basic test for rollback functionality using Git hard reset
internal/lint/fixer_file_ops.go Refactored Git operations to use go-git library; added isGitClean and rollback functions
internal/lint/fixer.go Added rollback calls on error paths and Git cleanliness check before fix operations
internal/lint/fixer_result.go Added AddLinkUpdate helper method to append link updates
docs/adr/adr-012-link-safe-file-normalization.md New ADR documenting the link-safe file normalization design
docs/README.md Added ADR-012 and other missing ADRs to the index
Comments suppressed due to low confidence (1)

internal/lint/fixer.go:180

  • Error handling uses standard library functions instead of the required internal/foundation/errors package. All error construction and wrapping should use the fluent API for consistent error classification. The fmt.Errorf calls on lines 73, 135, 148, 158, 172, and 180 should use errors.WrapError() or errors.NewError() as specified in ADR-000.
			return nil, fmt.Errorf("fix failed: %w (rollback also failed: %w)", err, rollbackErr)
		}
		return nil, err
	}
	return result, nil
}

// FixWithConfirmation fixes issues with interactive confirmation prompts.
// Shows a preview of changes and prompts the user before applying.
// Creates a backup of modified files before making changes.
//
//nolint:forbidigo // fmt is used for user-facing messages
func (f *Fixer) FixWithConfirmation(path string) (*FixResult, error) {
	// If in dry-run mode, no need for confirmation or backup
	if f.dryRun {
		return f.Fix(path)
	}

	// Phase 1: Preview changes (dry-run mode internally)
	originalDryRun := f.dryRun
	f.dryRun = true
	previewResult, err := f.Fix(path)
	f.dryRun = originalDryRun

	if err != nil {
		return nil, err
	}

	// If no changes to make, return early
	if !previewResult.HasChanges() {
		return previewResult, nil
	}

	// Phase 2: Show preview and get confirmation (unless auto-confirm)
	confirmed, err := f.ConfirmChanges(previewResult)
	if err != nil {
		return nil, fmt.Errorf("confirmation failed: %w", err)
	}

	if !confirmed {
		return previewResult, errors.New("user canceled operation")
	}

	// Phase 3: Create backup (always, even with auto-confirm)
	rootPath, err := filepath.Abs(path)
	if err != nil {
		return nil, fmt.Errorf("failed to get absolute path: %w", err)
	}

	backupDir, err := f.CreateBackup(previewResult, rootPath)
	if err != nil {
		return nil, fmt.Errorf("failed to create backup: %w", err)
	}

	if backupDir != "" {
		fmt.Printf("Created backup: %s\n", backupDir)
	}

	// Phase 4: Apply fixes
	result, err := f.fix(path)
	if err != nil {
		if rollbackErr := f.rollback(); rollbackErr != nil {
			return nil, fmt.Errorf("fix failed: %w (rollback also failed: %w)", err, rollbackErr)
		}
		return nil, err
	}
	return result, nil
}

// fix is the internal implementation that actually performs the fixes.
func (f *Fixer) fix(path string) (*FixResult, error) {
	// If git-aware, check for uncommitted changes to ensure safe rollback
	if f.gitAware && !f.dryRun && !f.force {
		clean, err := f.isGitClean(path)
		if err != nil {
			return nil, fmt.Errorf("failed to check git status: %w", err)
		}
		if !clean {
			return nil, errors.New("cannot fix: git repository has uncommitted changes. commit or stash them first, or use --force")
		}
	}

	// First, run linter to find issues
	result, err := f.linter.LintPath(path)
	if err != nil {
		return nil, fmt.Errorf("failed to lint path: %w", err)
	}

	fixResult := &FixResult{
		FilesRenamed: make([]RenameOperation, 0),
		LinksUpdated: make([]LinkUpdate, 0),
		Fingerprints: make([]FingerprintUpdate, 0),
		BrokenLinks:  make([]BrokenLink, 0),
		Errors:       make([]error, 0),
	}

	// Get absolute path for the root directory (for searching links)
	rootPath, err := filepath.Abs(path)
	if err != nil {
		return nil, fmt.Errorf("failed to get absolute path: %w", err)
	}

	// Detect broken links before applying fixes
	brokenLinks, err := detectBrokenLinks(path)
	if err != nil {
		// Non-fatal: log but continue with fixes
		fixResult.Errors = append(fixResult.Errors,
			fmt.Errorf("failed to detect broken links: %w", err))

inful and others added 10 commits January 20, 2026 15:57
Align fixer healing error handling with ADR-000 using the fluent builder
and classified errors for consistent reporting and context tracking.
- Add FullMatch to BrokenLink for surgical replacement
- Populate FullMatch in broken link detection (inline, reference, image)
- Update healing logic to use precise replacement avoiding non-link content corruption
- Preserve anchor fragments when healing links
- Fix linter issues (nilerr, gosec permissions) in healing logic and tests
- Replace fmt.Errorf and errors.New with internal/foundation/errors
- Wrap git and filesystem errors with appropriate categories
- Clean up unused fmt and errors imports
- Use git index to reliably check if a file is tracked before attempting 'git mv'
- Fix incorrect method call to git index in internal library
- Ensure all git operations use repository-relative paths
- Remove unused parameter from isGitClean for cleaner API
- Refactor fixer_file_ops.go to use foundation/errors for ADR-000 compliance
- Fix shouldUseGitMv to handle untracked files by checking git index
- Add comprehensive unit tests for updateLinkInFile covering fragments and edge cases
- Normalize test file permissions to 0o600 across lint package
- Improve rollback test fidelity by staging changes before resetting
- Add unit tests for FixResult helper methods
- Create helpers.SetupTestGitRepo in internal/testutil/testutils
- Refactor internal/lint, internal/git, and internal/daemon tests to use it
- Remove redundant boilerplate and redundant error checks in test setups
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 15 comments.

Comment on lines +18 to +94
func (f *Fixer) healBrokenLinks(fixResult *FixResult, _ map[string]struct{}, root string) {
if f.gitRepo == nil {
return
}

brokenLinks, err := detectBrokenLinks(root)
if err != nil || len(brokenLinks) == 0 {
return
}

for _, bl := range brokenLinks {
// Resolve the absolute path of the broken link target
absTarget, err := resolveRelativePath(bl.SourceFile, bl.Target)
if err != nil {
continue
}

// Attempt to find where absTarget went
newPath, err := f.findMovedFileInHistory(absTarget, root)
if err == nil && newPath != "" {
// Calculate relative link from SourceFile to newPath
relLink, err := filepath.Rel(filepath.Dir(bl.SourceFile), newPath)
if err == nil {
// Normalize for Markdown
relLink = filepath.ToSlash(relLink)

err = f.updateLinkInFile(bl, relLink)
if err == nil {
fixResult.AddLinkUpdate(bl.SourceFile, bl.Target, relLink)
}
}
}
}
}

// updateLinkInFile replaces a broken link target with a new one in the source file.
// It uses precise replacement based on the FullMatch metadata to avoid corrupting
// non-link content or link text.
func (f *Fixer) updateLinkInFile(bl BrokenLink, newRelTarget string) error {
// #nosec G304 -- bl.SourceFile is from discovery walkFiles, not user input
content, err := os.ReadFile(bl.SourceFile)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to read file").
WithContext("file", bl.SourceFile).
Build()
}

// Preserve fragment if present in original target
newLinkWithFragment := newRelTarget
if idx := strings.Index(bl.Target, "#"); idx != -1 {
newLinkWithFragment += bl.Target[idx:]
}

// Calculate the precise new full match
// We replace the target part within the full markdown link syntax.
newFullMatch := strings.Replace(bl.FullMatch, bl.Target, newLinkWithFragment, 1)

// Replace the old full match with the new one in the content.
// We use ReplaceAll because if the same exact link match appears multiple times in the file,
// they should all be healed.
newContent := strings.ReplaceAll(string(content), bl.FullMatch, newFullMatch)

if newContent == string(content) {
return errors.NewError(errors.CategoryNotFound, "link not found in file").
WithContext("file", bl.SourceFile).
WithContext("match", bl.FullMatch).
Build()
}

err = os.WriteFile(bl.SourceFile, []byte(newContent), 0o600)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to write file").
WithContext("file", bl.SourceFile).
Build()
}
return nil
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The healBrokenLinks and updateLinkInFile methods don't check the dryRun flag before modifying files. This means that even in dry-run mode, the healing phase will actually modify file contents. Add dryRun checks to skip actual file writes during preview mode, similar to how other fixer operations handle dry runs.

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +149
// Handle git-detected rename "old => new"
if strings.HasPrefix(stat.Name, relativeTarget+" => ") {
parts := strings.Split(stat.Name, " => ")
if len(parts) == 2 {
foundPath = filepath.Join(root, parts[1])
return errStop
}
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rename detection pattern matching "old => new" using strings.Split may be brittle if filenames contain " => " as part of their name. While unlikely, this could cause incorrect parsing. Consider using a more robust parsing approach or validating that the split results in exactly 2 non-empty parts before using them.

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +196
// rollback reverts all changes made during the fixer session using Git.
func (f *Fixer) rollback() error {
if f.gitRepo == nil || f.dryRun {
return nil
}

w, err := f.gitRepo.Worktree()
if err != nil {
return errors.WrapError(err, errors.CategoryGit, "failed to get git worktree").Build()
}

// Reset to initial SHA
err = w.Reset(&git.ResetOptions{
Commit: f.initialSHA,
Mode: git.HardReset,
})
if err != nil {
return errors.WrapError(err, errors.CategoryGit, "git reset failed").Build()
}

return nil
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollback function doesn't validate that initialSHA is non-empty before attempting to reset. If the Fixer was created in a repository with no commits or if the HEAD couldn't be read during initialization (see NewFixer), initialSHA will be empty and the reset operation will fail or reset to an unexpected state. Add a check to ensure initialSHA is valid before attempting rollback.

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 78
func (f *Fixer) Fix(path string) (*FixResult, error) {
return f.fix(path)
result, err := f.fix(path)
if err != nil {
if rollbackErr := f.rollback(); rollbackErr != nil {
return nil, fmt.Errorf("fix failed: %w (rollback also failed: %w)", err, rollbackErr)
}
return nil, err
}
return result, nil
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollback logic is called even when the Fixer is in dryRun mode. Although rollback() itself checks for dryRun, it's inefficient and semantically incorrect to attempt rollback during a dry run. Consider adding a check: "if !f.dryRun { if rollbackErr := f.rollback()... }" to skip the rollback call entirely during dry runs.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +50
for _, bl := range brokenLinks {
// Resolve the absolute path of the broken link target
absTarget, err := resolveRelativePath(bl.SourceFile, bl.Target)
if err != nil {
continue
}

// Attempt to find where absTarget went
newPath, err := f.findMovedFileInHistory(absTarget, root)
if err == nil && newPath != "" {
// Calculate relative link from SourceFile to newPath
relLink, err := filepath.Rel(filepath.Dir(bl.SourceFile), newPath)
if err == nil {
// Normalize for Markdown
relLink = filepath.ToSlash(relLink)

err = f.updateLinkInFile(bl, relLink)
if err == nil {
fixResult.AddLinkUpdate(bl.SourceFile, bl.Target, relLink)
}
}
}
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors encountered during the healing process are silently ignored (lines 32, 40, 45). This makes debugging difficult and hides potential issues. Consider logging errors at an appropriate level or accumulating them in fixResult.Errors so users are aware when healing operations fail.

Copilot uses AI. Check for mistakes.
"github.com/go-git/go-git/v5/plumbing/object"
)

var errStop = errors.NewError(errors.CategoryInternal, "stop iteration").Build()
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a sentinel error built from the internal errors package as a control flow mechanism (errStop) is inappropriate. Sentinel errors should be simple, meaningful errors that can be compared directly. For internal iteration control, use a plain standard library error like "var errStop = stdErrors.New("stop iteration")" instead. The foundation errors package is meant for user-facing or logged errors that need rich context and categorization.

Suggested change
var errStop = errors.NewError(errors.CategoryInternal, "stop iteration").Build()
var errStop = stdErrors.New("stop iteration")

Copilot uses AI. Check for mistakes.
Comment on lines 72 to 147
func (f *Fixer) shouldUseGitMv(filePath string) bool {
if !f.gitAware {
if f.gitRepo == nil {
return false
}

// Check if file is tracked by Git
cmd := exec.CommandContext(context.Background(), "git", "ls-files", "--error-unmatch", filePath)
err := cmd.Run()
// Use the git index to check if the file is tracked.
// Only tracked files can be moved with 'git mv'.
idx, err := f.gitRepo.Storer.Index()
if err != nil {
return false
}

// Git paths in the index are always relative to the repository root and use forward slashes.
// We assume f.gitRepo was opened at the root of the workspace being fixed (.).
absPath, err := filepath.Abs(filePath)
if err != nil {
return false
}
root, err := filepath.Abs(".")
if err != nil {
return false
}

relPath, err := filepath.Rel(root, absPath)
if err != nil || strings.HasPrefix(relPath, "..") {
return false // Path is outside repository
}

relPath = filepath.ToSlash(relPath)
_, err = idx.Entry(relPath)
return err == nil
}

// gitMv performs a git mv operation.
// gitMv performs a git mv operation using the Git library.
func (f *Fixer) gitMv(oldPath, newPath string) error {
cmd := exec.CommandContext(context.Background(), "git", "mv", oldPath, newPath)
output, err := cmd.CombinedOutput()
if f.gitRepo == nil {
return errors.NewError(errors.CategoryGit, "git repository not initialized").Build()
}

w, err := f.gitRepo.Worktree()
if err != nil {
return errors.WrapError(err, errors.CategoryGit, "failed to get git worktree").Build()
}

// Calculate paths relative to the repository root (.)
root, err := filepath.Abs(".")
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to get absolute path of current directory").Build()
}

absOld, err := filepath.Abs(oldPath)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to get absolute path of old file").Build()
}
relOld, err := filepath.Rel(root, absOld)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to get relative path of old file").Build()
}

absNew, err := filepath.Abs(newPath)
if err != nil {
return fmt.Errorf("%w: %s", err, string(output))
return errors.WrapError(err, errors.CategoryFileSystem, "failed to get absolute path of new file").Build()
}
relNew, err := filepath.Rel(root, absNew)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to get relative path of new file").Build()
}

// Perform the move using repository-relative paths
_, err = w.Move(filepath.ToSlash(relOld), filepath.ToSlash(relNew))
if err != nil {
return errors.WrapError(err, errors.CategoryGit, "failed to move file in git").Build()
}

return nil
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both shouldUseGitMv and gitMv resolve the repository root using filepath.Abs(".") which assumes the current working directory is the repository root. This is fragile and could fail if the fixer is called from a subdirectory. Consider storing the repository root path in the Fixer struct during initialization (obtained from gitRepo.Worktree().Filesystem.Root() or similar), or passing it as a parameter.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +110
// We only look back a few commits to avoid heavy processing
const maxCommits = 10
count := 0
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded limit of 10 commits (maxCommits) may be insufficient for repositories with frequent commits or long-running branches where a file was moved many commits ago. Consider making this configurable or documenting this limitation clearly so users understand why older renames might not be detected. Alternatively, consider using a time-based limit (e.g., last 30 days) instead of a commit count.

Copilot uses AI. Check for mistakes.
- `internal/lint/fixer.go`: Core orchestration logic using `internal/foundation/errors`.
- `internal/lint/fixer_file_ops.go`: `git mv` and Git-based rollback operations.
- `internal/lint/fixer_link_updates.go`: Atomic multi-file link rewriting.
- `internal/lint/fixer_link_detection.go`: Repository-scoped link discovery and `go-git` history tracking.
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ADR references internal/lint/fixer_link_detection.go as an implementation file, but this file doesn't exist in the PR changes. The actual implementation appears to be in fixer_healing.go. Update the reference to point to the correct file.

Suggested change
- `internal/lint/fixer_link_detection.go`: Repository-scoped link discovery and `go-git` history tracking.
- `internal/lint/fixer_healing.go`: Repository-scoped link discovery and `go-git` history tracking.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +94
// updateLinkInFile replaces a broken link target with a new one in the source file.
// It uses precise replacement based on the FullMatch metadata to avoid corrupting
// non-link content or link text.
func (f *Fixer) updateLinkInFile(bl BrokenLink, newRelTarget string) error {
// #nosec G304 -- bl.SourceFile is from discovery walkFiles, not user input
content, err := os.ReadFile(bl.SourceFile)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to read file").
WithContext("file", bl.SourceFile).
Build()
}

// Preserve fragment if present in original target
newLinkWithFragment := newRelTarget
if idx := strings.Index(bl.Target, "#"); idx != -1 {
newLinkWithFragment += bl.Target[idx:]
}

// Calculate the precise new full match
// We replace the target part within the full markdown link syntax.
newFullMatch := strings.Replace(bl.FullMatch, bl.Target, newLinkWithFragment, 1)

// Replace the old full match with the new one in the content.
// We use ReplaceAll because if the same exact link match appears multiple times in the file,
// they should all be healed.
newContent := strings.ReplaceAll(string(content), bl.FullMatch, newFullMatch)

if newContent == string(content) {
return errors.NewError(errors.CategoryNotFound, "link not found in file").
WithContext("file", bl.SourceFile).
WithContext("match", bl.FullMatch).
Build()
}

err = os.WriteFile(bl.SourceFile, []byte(newContent), 0o600)
if err != nil {
return errors.WrapError(err, errors.CategoryFileSystem, "failed to write file").
WithContext("file", bl.SourceFile).
Build()
}
return nil
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The updateLinkInFile method doesn't follow the same safety guarantees as applyLinkUpdates in fixer_link_updates.go. The healing code lacks backup creation before modifications and doesn't implement transactional rollback if an error occurs after partial writes. Consider refactoring to use the same backup/restore pattern or documenting why the simpler approach is acceptable for healing operations.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants