#!/usr/bin/perl
#
# Copyright (c) 2026 SUSE LLC, Adrian Schroeter
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program (see the file COPYING); if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
#
################################################################
#
# Three-way merge driver for .gitmodules files.
#
# The order of submodule definitions and the order of lines within a
# definition do not matter semantically, so the merge is performed on
# the logical content.  The base order is preserved; new entries are
# inserted in alphabetical order (case-insensitive) to produce a
# canonical layout.
#
# Usage (as a git merge driver):
#   merge-git-modules %O %B %A > %A.tmp && mv %A.tmp %A
#
#   %O  base (common ancestor)
#   %B  theirs (branch being merged)
#   %A  ours (current branch) — read for input, overwritten via shell redirect
#
# Exits 0 on clean merge, non-zero on conflict (conflict markers
# are written to stdout so git presents the file to the user).
#

use strict;
use warnings;

sub echo_help {
  print "
merge-git-modules — three-way merge driver for .gitmodules
==========================================================

Merges two .gitmodules files using a common ancestor (base).  The order
of submodule definitions and the order of lines within a definition do
not matter semantically, so the merge is performed on the logical content.

The merged result is written to stdout.  The exit status indicates whether
the merge succeeded cleanly (0) or produced conflicts (1).

Usage:
  merge-git-modules BASE THEIRS OURS
  merge-git-modules --format FILE [--set-branch BRANCH]
  merge-git-modules --help

Arguments (merge mode):
  BASE    common ancestor .gitmodules (from %O in a merge driver)
  THEIRS  version from the branch being merged in (from %B)
  OURS    current branch version, read for input (from %A)

Options:
  --format FILE   Read FILE and print it to stdout sorted by submodule
                  name (case-insensitive).  Keys within each definition
                  are also sorted.  Useful as a pre-commit formatter.
  --set-branch BRANCH
                  Used with --format: set every submodule's branch to
                  BRANCH.  Existing branch entries are replaced; missing
                  ones are added.  Can only be used with --format.
  --help          Show this help and exit.

Behaviour:
  * Base entries keep their relative order to minimise diffs.
  * New entries (not in base) are inserted in alphabetical order
    (case-insensitive).
  * Deletion wins: if one side deletes a submodule and the other
    leaves it unchanged, the submodule is removed.
  * Conflict: both sides modify the same submodule differently, or
    one side modifies while the other deletes.  Conflict markers
    are written to stdout and the tool exits non-zero so git
    presents the file to the user for manual resolution.

Exit status:
  0  clean merge (or no changes needed)
  1  conflict — conflict markers on stdout

Example setup in .gitattributes and .git/config:

  # .gitattributes
  .gitmodules merge=merge-gitmodules

  # .git/config
  [merge \"merge-gitmodules\"]
    name = merge .gitmodules files
    driver = /usr/lib/pool/helper/merge-git-modules %O %B %A > %A.tmp && mv %A.tmp %A
";
}

if (@ARGV == 1 && $ARGV[0] eq '--help') {
  echo_help();
  exit(0);
}

# ── --format mode (optionally with --set-branch) ──

my $set_branch;
my $format_file;
while (@ARGV) {
  if ($ARGV[0] eq '--set-branch' && @ARGV > 1) {
    shift @ARGV;
    $set_branch = shift @ARGV;
  } elsif ($ARGV[0] eq '--format' && @ARGV > 1) {
    shift @ARGV;
    $format_file = shift @ARGV;
  } else {
    last;
  }
}

if (defined $format_file) {
  my $list = parse_gitmodules($format_file);
  for my $b (sort { lc($a->[0]) cmp lc($b->[0]) } @$list) {
    my ($name, $vals) = @$b;
    if (defined $set_branch) {
      $vals->{branch} = $set_branch;
    }
    print "[submodule \"$name\"]\n";
    for my $k (sort keys %$vals) {
      print "\t$k = $vals->{$k}\n";
    }
  }
  exit(0);
}

die "Usage: merge-git-modules [--help] | [--format FILE [--set-branch BRANCH]] | BASE THEIRS OURS\n" unless @ARGV == 3;

my ($base_file, $theirs_file, $ours_file) = @ARGV;

# ── shared merge state ──

my @blocks;       # ordered list of [name, {key=>val}] for the result
my $conflict = 0;
my %emitted;

# ── parse .gitmodules into an ordered list of [name, {key=>val}] ──

sub parse_gitmodules {
  my ($file) = @_;
  open(my $fh, '<', $file) or die "Cannot open $file: $!\n";

  my @entries;
  my $cur_name;
  my %cur_vals;

  while (my $line = <$fh>) {
    chomp $line;

    next if $line =~ /^\s*$/ || $line =~ /^\s*[#;]/;

    if ($line =~ /^\s*\[\s*submodule\s+"([^"]+)"\s*\]\s*$/) {
      if (defined $cur_name) {
        push @entries, [$cur_name, { %cur_vals }];
      }
      $cur_name = $1;
      %cur_vals = ();
      next;
    }

    if (defined $cur_name && $line =~ /^\s*(\S+)\s*=\s*(.*?)\s*$/) {
      my ($k, $v) = ($1, $2);
      $v =~ s/^"(.*)"$/$1/;
      $v =~ s/^'(.*)'$/$1/;
      $cur_vals{$k} = $v;
      next;
    }
  }

  push @entries, [$cur_name, { %cur_vals }] if defined $cur_name;

  close $fh;
  return \@entries;
}

# ── compare two submodule definitions (order-independent) ──

sub defs_equal {
  my ($a, $b) = @_;
  return 0 if scalar(keys %$a) != scalar(keys %$b);
  for my $k (keys %$a) {
    return 0 unless exists $b->{$k} && ($a->{$k} // '') eq ($b->{$k} // '');
  }
  return 1;
}

# ── insert a new block in alphabetical position among @blocks ──

sub insert_block_sorted {
  my ($name, $vals) = @_;
  my $lc = lc($name);
  my $insert_after = 0;
  for my $i (0 .. $#blocks) {
    $insert_after = $i + 1 if lc($blocks[$i][0]) lt $lc;
  }
  splice(@blocks, $insert_after, 0, [$name, $vals]);
}

# ── write result to stdout ──

sub write_output {
  for my $b (@blocks) {
    my ($name, $vals) = @$b;
    print "[submodule \"$name\"]\n";
    for my $k (sort keys %$vals) {
      print "\t$k = $vals->{$k}\n";
    }
  }
  exit(1) if $conflict;
  exit(0);
}

# ── main ──

my $base_list   = parse_gitmodules($base_file);
my $theirs_list = parse_gitmodules($theirs_file);
my $ours_list   = parse_gitmodules($ours_file);

my %base_h   = map { $_->[0] => $_->[1] } @$base_list;
my %theirs_h = map { $_->[0] => $_->[1] } @$theirs_list;
my %ours_h   = map { $_->[0] => $_->[1] } @$ours_list;

# ── step 1: walk base order (template) ──

for my $entry (@$base_list) {
  my $name = $entry->[0];
  my $bval = $entry->[1];
  my $in_ours   = exists $ours_h{$name};
  my $in_theirs = exists $theirs_h{$name};

  if (!$in_ours && !$in_theirs) {
    $emitted{$name} = 1;
    next;
  }

  if (!$in_ours && $in_theirs) {
    if (defs_equal($bval, $theirs_h{$name})) {
      # theirs unchanged, ours deleted → delete wins
      $emitted{$name} = 1;
      next;
    }
    # theirs modified, ours deleted → conflict
    $conflict = 1;
    push @blocks, ["<<<<<<<", {}];
    push @blocks, ["=======", {}];
    push @blocks, [$name, $theirs_h{$name}];
    push @blocks, [">>>>>>>", {}];
    $emitted{$name} = 1;
    next;
  }
  if ($in_ours && !$in_theirs) {
    if (defs_equal($bval, $ours_h{$name})) {
      # ours unchanged, theirs deleted → delete wins
      $emitted{$name} = 1;
      next;
    }
    # ours modified, theirs deleted → conflict
    $conflict = 1;
    push @blocks, ["<<<<<<<", {}];
    push @blocks, [$name, $ours_h{$name}];
    push @blocks, ["=======", {}];
    push @blocks, [">>>>>>>", {}];
    $emitted{$name} = 1;
    next;
  }

  my $oval = $ours_h{$name};
  my $tval = $theirs_h{$name};
  my $o_changed = !defs_equal($bval, $oval);
  my $t_changed = !defs_equal($bval, $tval);

  if (!$o_changed && !$t_changed) {
    push @blocks, [$name, $bval];
  } elsif ($o_changed && !$t_changed) {
    push @blocks, [$name, $oval];
  } elsif (!$o_changed && $t_changed) {
    push @blocks, [$name, $tval];
  } else {
    if (defs_equal($oval, $tval)) {
      push @blocks, [$name, $oval];
    } else {
      $conflict = 1;
      push @blocks, ["<<<<<<<", {}];
      push @blocks, [$name, $oval];
      push @blocks, ["=======", {}];
      push @blocks, [$name, $tval];
      push @blocks, [">>>>>>>", {}];
    }
  }
  $emitted{$name} = 1;
}

# ── step 2: insert new entries (not in base) in alphabetical order ──

for my $entry (@$ours_list) {
  my $name = $entry->[0];
  next if $emitted{$name};
  insert_block_sorted($name, $ours_h{$name});
  $emitted{$name} = 1;
}

for my $entry (@$theirs_list) {
  my $name = $entry->[0];
  next if $emitted{$name};
  insert_block_sorted($name, $theirs_h{$name});
  $emitted{$name} = 1;
}

write_output();
