intellij: add localconfig helper

Adds a little helper tool which merges a watcherTasks template with the local
project config. This restores the functionality lost in D658.

Also cured me of any remaining nostalgic feelings towards XML.

Test Plan:
Deleted all watchers, ran the script, re-opened project,
watchers are back and functional. Local watchers with the same name got
overwritten. Additional watchers were untouched.

X-Origin-Diff: phab/D661
GitOrigin-RevId: 83f7c1506476378145781c816d776fd451aed40c
diff --git a/intellij/localconfig/BUILD.bazel b/intellij/localconfig/BUILD.bazel
new file mode 100644
index 0000000..9125284
--- /dev/null
+++ b/intellij/localconfig/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["localconfig.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/intellij/localconfig",
+    visibility = ["//visibility:private"],
+    deps = ["//intellij/localconfig/watchers:go_default_library"],
+)
+
+go_binary(
+    name = "localconfig",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/intellij/localconfig/data/watcherTasks.xml b/intellij/localconfig/data/watcherTasks.xml
new file mode 100644
index 0000000..4d6e6a8
--- /dev/null
+++ b/intellij/localconfig/data/watcherTasks.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+    <component name="ProjectTasksOptions">
+        <TaskOptions isEnabled="true">
+            <option name="arguments" value="build //core/api/... //core/internal/..." />
+            <option name="checkSyntaxErrors" value="true" />
+            <option name="description" />
+            <option name="exitCodeBehavior" value="ERROR" />
+            <option name="fileExtension" value="proto" />
+            <option name="immediateSync" value="false" />
+            <option name="name" value="Regenerate protobuf files" />
+            <option name="output" value="" />
+            <option name="outputFilters">
+                <array />
+            </option>
+            <option name="outputFromStdout" value="false" />
+            <option name="program" value="$WorkspaceRoot$/scripts/bin/bazel" />
+            <option name="runOnExternalChanges" value="true" />
+            <option name="scopeName" value="All Places" />
+            <option name="trackOnlyRoot" value="false" />
+            <option name="workingDir" value="" />
+            <envs />
+        </TaskOptions>
+        <TaskOptions isEnabled="true">
+            <option name="arguments" value="-local git.monogon.dev -w $FilePath$" />
+            <option name="checkSyntaxErrors" value="true" />
+            <option name="description" />
+            <option name="exitCodeBehavior" value="ERROR" />
+            <option name="fileExtension" value="go" />
+            <option name="immediateSync" value="false" />
+            <option name="name" value="goimports" />
+            <option name="output" value="$FilePath$" />
+            <option name="outputFilters">
+                <array />
+            </option>
+            <option name="outputFromStdout" value="false" />
+            <option name="program" value="$USER_HOME$/.local/bin/goimports" />
+            <option name="runOnExternalChanges" value="false" />
+            <option name="scopeName" value="Project Files" />
+            <option name="trackOnlyRoot" value="true" />
+            <option name="workingDir" value="$ProjectFileDir$" />
+            <envs>
+                <env name="GOROOT" value="$GOROOT$" />
+                <env name="GOPATH" value="$GOPATH$" />
+                <env name="PATH" value="$GoBinDirs$" />
+            </envs>
+        </TaskOptions>
+    </component>
+</project>
diff --git a/intellij/localconfig/localconfig.go b/intellij/localconfig/localconfig.go
new file mode 100644
index 0000000..44ef730
--- /dev/null
+++ b/intellij/localconfig/localconfig.go
@@ -0,0 +1,50 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// localconfig modifies the project's IntelliJ config to include project-specific settings. This is usually done by
+// checking in the .idea directory, but we do not want to do this: it conflicts with the Bazel plugin's way of
+// conducting its workspace business, lacks backwards compatibility, and is a common source of spurious Git diffs,
+// especially when the IDE/JDK/random plugins are updated and team members run different versions.
+//
+// Instead, we use the officially supported way of shipping IntelliJ Bazel project configs - a .bazelproject file that
+// can be imported using the Bazel project import wizard, with local configs. We then use this tool to mangle the local
+// configs to add additional custom configuration beyond run configurations. This avoids merge conflicts and allows us
+// to intelligently handle schema changes.
+//
+package main
+
+import (
+	"log"
+	"os"
+	"path"
+
+	"git.monogon.dev/source/nexantic.git/intellij/localconfig/watchers"
+)
+
+func main() {
+	if len(os.Args) != 2 {
+		log.Fatal("usage: localconfig <project dir>")
+	}
+
+	projectDir := os.Args[1]
+	if _, err := os.Stat(path.Join(projectDir, ".ijwb")); err != nil {
+		log.Fatalf("invalid project dir: %v", err)
+	}
+
+	if err := watchers.RewriteConfig(projectDir); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/intellij/localconfig/watchers/BUILD.bazel b/intellij/localconfig/watchers/BUILD.bazel
new file mode 100644
index 0000000..bae47c7
--- /dev/null
+++ b/intellij/localconfig/watchers/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["filewatchers.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/intellij/localconfig/watchers",
+    visibility = ["//visibility:public"],
+)
diff --git a/intellij/localconfig/watchers/filewatchers.go b/intellij/localconfig/watchers/filewatchers.go
new file mode 100644
index 0000000..f31a26a
--- /dev/null
+++ b/intellij/localconfig/watchers/filewatchers.go
@@ -0,0 +1,160 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package watchers
+
+import (
+	"encoding/xml"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+)
+
+const (
+	fileWatchersPath = ".ijwb/.idea/watcherTasks.xml"
+	templatePath     = "intellij/localconfig/data/watcherTasks.xml"
+)
+
+type config struct {
+	XMLName   xml.Name  `xml:"project"`
+	Version   string    `xml:"version,attr"`
+	Component component `xml:"component"`
+}
+
+type component struct {
+	Name        string       `xml:"name,attr"`
+	TaskOptions []taskOption `xml:"TaskOptions"`
+}
+
+type taskOption struct {
+	IsEnabled string `xml:"isEnabled,attr"`
+	Option    []struct {
+		Name  string `xml:"name,attr"`
+		Value string `xml:"value,attr,omitempty"`
+		Data  string `xml:",innerxml"`
+	} `xml:"option"`
+	Envs struct {
+		Env []struct {
+			Name  string `xml:"name,attr"`
+			Value string `xml:"value,attr"`
+		} `xml:"env"`
+	} `xml:"envs"`
+}
+
+func buildConfig(options []taskOption) *config {
+	return &config{
+		XMLName: xml.Name{Local: "project"},
+		Version: "4",
+		Component: component{
+			Name:        "ProjectTasksOptions",
+			TaskOptions: options,
+		},
+	}
+}
+
+func readConfig(filename string) (cfg *config, err error) {
+	b, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return nil, fmt.Errorf("failed reading file: %w", err)
+	}
+
+	err = xml.Unmarshal(b, &cfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed deserializing XML: %w", err)
+	}
+
+	return
+}
+
+func (cfg *config) atomicWriteFile(filename string) error {
+	b, err := xml.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to serialize: %w", err)
+	}
+
+	// Atomic write is needed, IntelliJ has inotify watches on its config and reloads (but not applies) instantly.
+	tmpPath := filename + ".tmp"
+	defer os.Remove(tmpPath)
+	if err := ioutil.WriteFile(tmpPath, []byte(xml.Header+string(b)), 0664); err != nil {
+		return fmt.Errorf("failed to write: %w", err)
+	}
+	if err := os.Rename(tmpPath, filename); err != nil {
+		return fmt.Errorf("failed to rename: %w", err)
+	}
+
+	return nil
+}
+
+// RewriteConfig adds our watchers to projectDir's watchers config, overwriting existing entries with the same name.
+func RewriteConfig(projectDir string) error {
+	template, err := readConfig(path.Join(projectDir, templatePath))
+	if err != nil {
+		return fmt.Errorf("failed reading template config: %w", err)
+	}
+
+	if template.Version != "4" {
+		return fmt.Errorf("unknown template config version: %s", template.Version)
+	}
+
+	// Read existing tasks, if any.
+	tasks := make(map[string]taskOption)
+	cfg, err := readConfig(path.Join(projectDir, fileWatchersPath))
+
+	switch {
+	case err == nil:
+		// existing config, read tasks
+		if cfg.Version != "4" {
+			return fmt.Errorf("unknown watchers config version: %s", cfg.Version)
+		}
+		for _, v := range cfg.Component.TaskOptions {
+			for _, o := range v.Option {
+				if o.Name == "name" {
+					tasks[o.Value] = v
+				}
+			}
+		}
+	case os.IsNotExist(err):
+		// no existing config - continue with empty tasks
+	default:
+		// error is non-nil and not an ENOENT
+		return fmt.Errorf("failed reading existing config: %w", err)
+	}
+
+	// Overwrite "our" entries, identified by name.
+	for _, v := range template.Component.TaskOptions {
+		for _, o := range v.Option {
+			if o.Name == "name" {
+				tasks[o.Value] = v
+			}
+		}
+	}
+
+	// Build new configuration
+	options := make([]taskOption, 0, len(tasks))
+	for _, t := range tasks {
+		options = append(options, t)
+	}
+
+	out := buildConfig(options)
+
+	err = out.atomicWriteFile(path.Join(projectDir, fileWatchersPath))
+	if err != nil {
+		return fmt.Errorf("failed writing to output file: %w", err)
+	}
+
+	return nil
+}