Skip to content

feat(dotfile): add advanced merge support for config files#104

Merged
AnnatarHe merged 1 commit intomainfrom
feat/issue-101-dotfile-merge
Sep 26, 2025
Merged

feat(dotfile): add advanced merge support for config files#104
AnnatarHe merged 1 commit intomainfrom
feat/issue-101-dotfile-merge

Conversation

@AnnatarHe
Copy link
Copy Markdown
Contributor

Summary

  • Implement custom Save method for GhosttyApp with intelligent config merging
  • Add key-value parsing with comment and blank line preservation
  • Support local priority merging where local settings override remote

Description

This PR addresses issue #101 by implementing an advanced merge feature for dotfile configurations, starting with Ghostty terminal config files.

Features

  • Smart parsing: Parses config files as key-value pairs while preserving comments and blank lines
  • Local priority merging: Local configurations always take precedence over remote settings
  • New key incorporation: Automatically adds new keys from remote that don't exist locally
  • Dry-run support: Shows detailed diff of what would change without modifying files
  • Format preservation: Maintains original formatting including whitespace and comments

Implementation Details

The new Save method in GhosttyApp:

  1. Reads and parses both local and remote config files
  2. Merges them with local values taking priority
  3. Adds any new keys from remote that don't exist locally
  4. In dry-run mode, displays what changes would be applied
  5. Preserves the original structure and formatting of the config file

This approach can be extended to other dotfile applications that use similar key-value configuration formats.

Test plan

  • Test with existing Ghostty config file
  • Test merging with remote config containing new keys
  • Verify local settings override remote settings
  • Verify dry-run mode shows correct diff
  • Test with missing local config file
  • Verify comments and blank lines are preserved

Fixes #101

🤖 Generated with Claude Code

Implement custom Save method for GhosttyApp that:
- Parses key-value config format with comment preservation
- Merges remote and local configs with local priority
- Shows detailed diff in dry-run mode
- Preserves formatting including blank lines and comments

This enhancement enables intelligent config merging where local
settings always take precedence while incorporating new keys
from remote configs.

Fixes #101

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Sep 17, 2025

Claude finished @AnnatarHe's task —— View job


PR Review Results

I've completed a comprehensive review of the Ghostty config merge implementation. Here's my detailed feedback:

Todo List:

  • Read and analyze the modified file (model/dotfile_ghostty.go)
  • Review code quality and best practices
  • Check for potential bugs or issues
  • Evaluate performance considerations
  • Assess security concerns
  • Analyze test coverage
  • Provide comprehensive feedback

Strengths

  1. Clean Architecture: Good separation of concerns with focused helper methods (parseGhosttyConfig, mergeGhosttyConfigs, formatGhosttyConfig)
  2. Format Preservation: Excellent handling of comments and blank lines through the configLine struct
  3. Local Priority Logic: Correctly implements local-takes-precedence merging strategy
  4. Dry-run Support: Well-implemented preview functionality showing what would change
  5. Error Handling: Appropriate use of logging for non-fatal errors and graceful continuation

🐛 Potential Bugs & Issues

  1. Key=Value Parsing Edge Cases (model/dotfile_ghostty.go:66):

    } else if strings.Contains(line, "=") {
    • Lines like command=echo "key=value" would be incorrectly split
    • Consider using regex or more sophisticated parsing for quoted values
  2. Whitespace Handling Inconsistency (model/dotfile_ghostty.go:123):

    result = append(result, fmt.Sprintf("%s = %s", line.key, line.value))
    • Always formats as key = value but original might have been key=value or key = value
    • This breaks the "format preservation" promise
  3. Missing Input Validation (model/dotfile_ghostty.go:131):

    • No validation that files map is non-nil
    • No validation of file paths for potential directory traversal

🚨 Security Concerns

  1. Path Traversal Risk (model/dotfile_ghostty.go:132):

    • While expandPath is used, there's no explicit validation against ../ sequences in the input paths
    • Malicious remote configs could potentially specify dangerous paths
  2. File Permissions (model/dotfile_ghostty.go:200, 206):

    • Directory creation uses 0755 (good)
    • File writing uses 0644 (good, not executable)
    • However, no validation of existing file permissions before overwriting

Performance Considerations

  1. Memory Usage:

    • Creates multiple copies of config data (localLines, remoteLines, merged)
    • For large configs, this could be memory-intensive
    • Consider streaming approach for very large files
  2. String Operations (model/dotfile_ghostty.go:118-127):

    • Uses strings.Join efficiently
    • Good use of slice preallocation where possible

📝 Code Quality Improvements

  1. Method Naming:

    • parseGhosttyConfig, mergeGhosttyConfigs, formatGhosttyConfig - could be more generic (parseKeyValueConfig) for reuse
  2. Magic Numbers:

    • Consider constants for file permissions (0755, 0644)
  3. Error Messages:

    • Could be more descriptive about what specifically failed

Critical Missing: Test Coverage

This is the biggest concern. The PR adds 189 lines of complex logic with zero test coverage:

  • No tests for the parsing logic (especially edge cases)
  • No tests for the merge functionality
  • No tests for the dry-run diff display
  • No tests for error handling paths
  • No integration tests

Recommended Test Cases:

func TestParseGhosttyConfig(t *testing.T) {
    // Test cases for comments, blank lines, key=value, edge cases
}

func TestMergeGhosttyConfigs(t *testing.T) {
    // Test local priority, new key addition, existing key preservation
}

func TestGhosttyAppSave(t *testing.T) {
    // Test dry-run mode, actual save, error conditions
}

🔧 Recommendations

  1. High Priority: Add comprehensive unit tests covering all the parsing and merging logic
  2. Medium Priority: Fix the whitespace formatting inconsistency to truly preserve original format
  3. Medium Priority: Improve key=value parsing to handle quoted strings properly
  4. Low Priority: Add input validation for security hardening

📊 Overall Assessment

The implementation is functionally sound and well-structured, but lacks test coverage which is critical for config file manipulation logic. The core merge algorithm is correct, but there are some edge cases in parsing that should be addressed.

Recommendation: Request tests before merge - This type of file manipulation logic is prone to edge case bugs and needs thorough testing.


Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Summary of Changes

Hello @AnnatarHe, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces an advanced configuration merging system for dotfiles, specifically starting with Ghostty terminal configurations. The primary goal is to provide a robust mechanism for synchronizing configuration files by intelligently merging remote settings with local ones, ensuring local preferences are maintained, new remote keys are incorporated, and original file formatting is preserved. This feature also includes a dry-run capability to preview changes safely, aiming to resolve issue #101.

Highlights

  • Intelligent Config Merging: Implemented a custom Save method for GhosttyApp to intelligently merge configuration files, addressing issue #101.
  • Key-Value Parsing: Introduced smart parsing of config files into key-value pairs, preserving comments and blank lines.
  • Local Priority: Ensured local configuration settings always take precedence over remote settings during merges.
  • New Key Incorporation: Automatically adds new keys from remote configurations if they do not exist locally.
  • Dry-Run Support: Added a dry-run mode to preview changes and show a detailed diff before modifying any files.
  • Format Preservation: Maintained original formatting, including whitespace and comments, throughout the merge process.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@codecov
Copy link
Copy Markdown

codecov Bot commented Sep 17, 2025

Codecov Report

❌ Patch coverage is 0% with 124 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
model/dotfile_ghostty.go 0.00% 124 Missing ⚠️
Flag Coverage Δ
unittests 23.07% <0.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
model/dotfile_ghostty.go 0.00% <0.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an intelligent merging mechanism for Ghostty configuration files, which is a great enhancement. The implementation correctly prioritizes local settings and incorporates new keys from remote configurations while preserving comments and blank lines. My review includes a few suggestions to improve robustness and maintainability: one to fully preserve file formatting as intended, another to refactor some redundant logic in the dry-run mode, and a third to improve error handling to ensure failures are correctly reported.

Comment thread model/dotfile_ghostty.go
Comment on lines +118 to +126
var result []string
for _, line := range lines {
if line.isComment || line.isBlank {
result = append(result, line.raw)
} else {
result = append(result, fmt.Sprintf("%s = %s", line.key, line.value))
}
}
return strings.Join(result, "\n")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation re-formats all key-value pairs using fmt.Sprintf("%s = %s", line.key, line.value). This alters the original formatting (e.g., spacing around =) and could strip inline comments. The PR description states that a goal is to "Maintain original formatting including whitespace and comments".

To better adhere to this goal, you can leverage the raw field of the configLine struct, which stores the original line from the file. By using line.raw for all lines, you ensure that formatting is perfectly preserved for existing lines. This also simplifies the function.

 var result []string
 for _, line := range lines {
  result = append(result, line.raw)
 }
 return strings.Join(result, "\n")

Comment thread model/dotfile_ghostty.go
Comment on lines +130 to +214
func (g *GhosttyApp) Save(ctx context.Context, files map[string]string, isDryRun bool) error {
for path, remoteContent := range files {
expandedPath, err := g.expandPath(path)
if err != nil {
logrus.Warnf("Failed to expand path %s: %v", path, err)
continue
}

// Read existing local content if file exists
var localContent string
if existingBytes, err := os.ReadFile(expandedPath); err == nil {
localContent = string(existingBytes)
} else if !os.IsNotExist(err) {
logrus.Warnf("Failed to read existing file %s: %v", expandedPath, err)
continue
}

// Parse both configs
localLines := g.parseGhosttyConfig(localContent)
remoteLines := g.parseGhosttyConfig(remoteContent)

// Merge configs (local has priority)
mergedLines := g.mergeGhosttyConfigs(localLines, remoteLines)
mergedContent := g.formatGhosttyConfig(mergedLines)

// Check if there are any differences
if localContent == mergedContent {
logrus.Infof("No changes needed for %s", expandedPath)
continue
}

if isDryRun {
// In dry-run mode, show the diff
fmt.Printf("\n📄 %s:\n", expandedPath)
fmt.Println("--- Changes to be applied ---")

// Show added keys (from remote)
remoteKeys := make(map[string]string)
for _, line := range remoteLines {
if !line.isComment && !line.isBlank && line.key != "" {
remoteKeys[line.key] = line.value
}
}

localKeys := make(map[string]string)
for _, line := range localLines {
if !line.isComment && !line.isBlank && line.key != "" {
localKeys[line.key] = line.value
}
}

// Show new keys from remote
hasChanges := false
for key, value := range remoteKeys {
if _, exists := localKeys[key]; !exists {
fmt.Printf("+ %s = %s (from remote)\n", key, value)
hasChanges = true
}
}

if !hasChanges {
fmt.Println("No new keys from remote")
}

fmt.Println("--- End of changes ---")
continue
}

// Ensure directory exists
dir := filepath.Dir(expandedPath)
if err := os.MkdirAll(dir, 0755); err != nil {
logrus.Warnf("Failed to create directory %s: %v", dir, err)
continue
}

// Write merged content
if err := os.WriteFile(expandedPath, []byte(mergedContent), 0644); err != nil {
logrus.Warnf("Failed to save file %s: %v", expandedPath, err)
} else {
logrus.Infof("Saved merged config to %s", expandedPath)
}
}

return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The Save function iterates through a map of files, but if an error occurs (e.g., path expansion, file read, file write), it logs a warning and uses continue to proceed to the next file. The function then returns nil at the end, regardless of whether errors occurred. This can cause the calling command to report success even if some or all files failed to save.

To provide more accurate feedback on the operation's success, it would be better to collect any errors that occur within the loop and return a consolidated error at the end. This ensures that failures are propagated up and can be handled appropriately (e.g., by exiting with a non-zero status code).

Example of aggregating errors:

func (g *GhosttyApp) Save(...) error {
    var errs []error
    for path, remoteContent := range files {
        // ... on error
        if err != nil {
            errs = append(errs, fmt.Errorf("failed to process %s: %w", path, err))
            logrus.Warnf("Failed to process %s: %v", path, err)
            continue
        }
        // ...
    }
    if len(errs) > 0 {
        // Using a modern approach to join errors
        return errors.Join(errs...)
    }
    return nil
}

Comment thread model/dotfile_ghostty.go
Comment on lines +167 to +188
remoteKeys := make(map[string]string)
for _, line := range remoteLines {
if !line.isComment && !line.isBlank && line.key != "" {
remoteKeys[line.key] = line.value
}
}

localKeys := make(map[string]string)
for _, line := range localLines {
if !line.isComment && !line.isBlank && line.key != "" {
localKeys[line.key] = line.value
}
}

// Show new keys from remote
hasChanges := false
for key, value := range remoteKeys {
if _, exists := localKeys[key]; !exists {
fmt.Printf("+ %s = %s (from remote)\n", key, value)
hasChanges = true
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The dry-run block re-creates localKeys and remoteKeys maps and iterates through them to find new keys. This logic is redundant because mergeGhosttyConfigs already performs a similar operation to identify which remote keys are new.

To improve efficiency and reduce code duplication, you could refactor mergeGhosttyConfigs to also return the list of configLines that were added from the remote config. The Save method could then use this list directly for the dry-run output.

Example refactoring:

// In mergeGhosttyConfigs
func (g *GhosttyApp) mergeGhosttyConfigs(...) ([]configLine, []configLine) {
    // ...
    var addedLines []configLine
    for _, remoteLine := range remoteLines {
        if !localKeys[remoteLine.key] {
            merged = append(merged, remoteLine)
            addedLines = append(addedLines, remoteLine)
        }
    }
    return merged, addedLines
}

// In Save method
mergedLines, addedLines := g.mergeGhosttyConfigs(localLines, remoteLines)
// ...
if isDryRun {
    // ...
    for _, line := range addedLines {
        fmt.Printf("+ %s = %s (from remote)\n", line.key, line.value)
    }
    // ...
}

This change would make the code more DRY (Don't Repeat Yourself) and slightly more performant.

@AnnatarHe AnnatarHe merged commit eaf7ea2 into main Sep 26, 2025
7 of 8 checks passed
@AnnatarHe AnnatarHe deleted the feat/issue-101-dotfile-merge branch September 26, 2025 13:18
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.

[dotfiles pull] enhance the pull command that can merge by syntax validate way

1 participant