diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/storage/file/UserConfigFileTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/storage/file/UserConfigFileTest.java new file mode 100644 index 000000000..7d212d540 --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/storage/file/UserConfigFileTest.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2023, Thomas Wolf and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.storage.file; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.eclipse.jgit.util.FS; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class UserConfigFileTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void testParentOnlyLoad() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + } + + @Test + public void testLoadBoth() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, "[user]\n\temail = a.u.thor@example.com"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + } + + @Test + public void testOverwriteChild() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, "[user]\n\temail = a.u.thor@example.com"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.setString("user", null, "name", "A U Thor"); + assertEquals("A U Thor", config.getString("user", null, "name")); + config.save(); + UserConfigFile config2 = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config2.load(); + assertEquals("A U Thor", config2.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + FileBasedConfig cfg = new FileBasedConfig(null, xdg.toFile(), + FS.DETECTED); + cfg.load(); + assertEquals("Archibald Ulysses Thor", + cfg.getString("user", null, "name")); + assertNull(cfg.getString("user", null, "email")); + } + + @Test + public void testUnset() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, "[user]\n\temail = a.u.thor@example.com"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.setString("user", null, "name", "A U Thor"); + assertEquals("A U Thor", config.getString("user", null, "name")); + config.unset("user", null, "name"); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.save(); + UserConfigFile config2 = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config2.load(); + assertEquals("Archibald Ulysses Thor", + config2.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + FileBasedConfig cfg = new FileBasedConfig(null, user.toFile(), + FS.DETECTED); + cfg.load(); + assertNull(cfg.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + cfg.getString("user", null, "email")); + } + + @Test + public void testUnsetSection() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, "[user]\n\temail = a.u.thor@example.com"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.unsetSection("user", null); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + config.save(); + assertTrue(Files.readString(user).strip().isEmpty()); + } + + @Test + public void testNoChild() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertNull(config.getString("user", null, "email")); + config.setString("user", null, "email", "a.u.thor@example.com"); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.save(); + assertFalse(Files.exists(user)); + UserConfigFile config2 = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config2.load(); + assertEquals("Archibald Ulysses Thor", + config2.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config2.getString("user", null, "email")); + } + + @Test + public void testNoFiles() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertNull(config.getString("user", null, "name")); + assertNull(config.getString("user", null, "email")); + config.setString("user", null, "name", "Archibald Ulysses Thor"); + config.setString("user", null, "email", "a.u.thor@example.com"); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + config.save(); + assertTrue(Files.exists(user)); + assertFalse(Files.exists(xdg)); + UserConfigFile config2 = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config2.load(); + assertEquals("Archibald Ulysses Thor", + config2.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config2.getString("user", null, "email")); + } + + @Test + public void testSetInXdg() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + config.setString("user", null, "email", "a.u.thor@example.com"); + config.save(); + assertFalse(Files.exists(user)); + FileBasedConfig cfg = new FileBasedConfig(null, xdg.toFile(), + FS.DETECTED); + cfg.load(); + assertEquals("Archibald Ulysses Thor", + cfg.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + cfg.getString("user", null, "email")); + } + + @Test + public void testUserConfigCreated() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Thread.sleep(3000); // Avoid racily clean isOutdated() below. + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + Files.writeString(user, + "[user]\n\temail = a.u.thor@example.com\n\tname = A U Thor"); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertTrue(config.isOutdated()); + config.load(); + assertEquals("A U Thor", config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + } + + @Test + public void testUserConfigDeleted() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, + "[user]\n\temail = a.u.thor@example.com\n\tname = A U Thor"); + Thread.sleep(3000); // Avoid racily clean isOutdated() below. + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("A U Thor", config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + Files.delete(user); + assertEquals("A U Thor", config.getString("user", null, "name")); + assertEquals("a.u.thor@example.com", + config.getString("user", null, "email")); + assertTrue(config.isOutdated()); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertNull(config.getString("user", null, "email")); + } + + @Test + public void testXdgConfigDeleted() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Thread.sleep(3000); // Avoid racily clean isOutdated() below. + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + Files.delete(xdg); + assertEquals("Archibald Ulysses Thor", + config.getString("user", null, "name")); + assertTrue(config.isOutdated()); + config.load(); + assertNull(config.getString("user", null, "name")); + } + + @Test + public void testXdgConfigDeletedUserConfigExists() throws Exception { + Path xdg = tmp.getRoot().toPath().resolve("xdg.cfg"); + Files.writeString(xdg, "[user]\n\tname = Archibald Ulysses Thor"); + Path user = tmp.getRoot().toPath().resolve("user.cfg"); + Files.writeString(user, + "[user]\n\temail = a.u.thor@example.com\n\tname = A U Thor"); + Thread.sleep(3000); // Avoid racily clean isOutdated() below. + UserConfigFile config = new UserConfigFile(null, user.toFile(), + xdg.toFile(), FS.DETECTED); + config.load(); + assertEquals("A U Thor", config.getString("user", null, "name")); + Files.delete(xdg); + assertTrue(config.isOutdated()); + config.load(); + assertEquals("A U Thor", config.getString("user", null, "name")); + } + +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/Config.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/Config.java index 0cccaec49..7e2c5b5ad 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/Config.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/Config.java @@ -739,7 +739,7 @@ protected void fireConfigChangedEvent() { listeners.dispatch(new ConfigChangedEvent()); } - String getRawString(final String section, final String subsection, + private String getRawString(final String section, final String subsection, final String name) { String[] lst = getRawStringList(section, subsection, name); if (lst != null) { diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/DefaultTypedConfigGetter.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/DefaultTypedConfigGetter.java index 80aceb4e7..a71549c92 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/lib/DefaultTypedConfigGetter.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/DefaultTypedConfigGetter.java @@ -34,7 +34,7 @@ public class DefaultTypedConfigGetter implements TypedConfigGetter { @Override public boolean getBoolean(Config config, String section, String subsection, String name, boolean defaultValue) { - String n = config.getRawString(section, subsection, name); + String n = config.getString(section, subsection, name); if (n == null) { return defaultValue; } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/FileBasedConfig.java b/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/FileBasedConfig.java index 910c5cbd8..7fdcc4d3e 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/FileBasedConfig.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/FileBasedConfig.java @@ -22,6 +22,8 @@ import java.io.File; import java.io.IOException; import java.text.MessageFormat; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.errors.LockFailedException; @@ -52,6 +54,8 @@ public class FileBasedConfig extends StoredConfig { private volatile ObjectId hash; + private AtomicBoolean exists = new AtomicBoolean(); + /** * Create a configuration with no default fallback. * @@ -99,6 +103,21 @@ public final File getFile() { return configFile; } + boolean exists() { + return exists.get(); + } + + @Override + public void setStringList(String section, String subsection, String name, + List values) { + super.setStringList(section, subsection, name, values); + } + + @Override + public void unsetSection(String section, String subsection) { + super.unsetSection(section, subsection); + } + /** * {@inheritDoc} *

@@ -144,6 +163,7 @@ public void load() throws IOException, ConfigInvalidException { clear(); snapshot = lastSnapshot[0]; } + exists.set(wasRead != null); } catch (IOException e) { throw e; } catch (Exception e) { diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/UserConfigFile.java b/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/UserConfigFile.java new file mode 100644 index 000000000..2ad74c23c --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/storage/file/UserConfigFile.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2023, Thomas Wolf and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.storage.file; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.util.FS; + +/** + * User (global) git config based on two possible locations, + * {@code ~/.gitconfig} and {@code $XDG_CONFIG_HOME/git/config}. + *

+ * For reading, both locations are considered, first the XDG file, then the file + * in the home directory. All updates occur in the last file read that exists, + * or in the home directory file if neither exists. In other words: if only the + * XDG file exists, it is updated, otherwise the home directory file is updated. + *

+ * + * @since 6.7 + */ +public class UserConfigFile extends FileBasedConfig { + + private final FileBasedConfig parent; + + /** + * Creates a new {@link UserConfigFile}. + * + * @param parent + * parent {@link Config}; may be {@code null} + * @param config + * {@link File} for {@code ~/.gitconfig} + * @param xdgConfig + * {@link File} for {@code $XDG_CONFIG_HOME/.gitconfig} + * @param fileSystem + * {@link FS} to use for the two files; normally + * {@link FS#DETECTED} + */ + public UserConfigFile(Config parent, @NonNull File config, + @NonNull File xdgConfig, @NonNull FS fileSystem) { + super(new FileBasedConfig(parent, xdgConfig, fileSystem), config, + fileSystem); + this.parent = (FileBasedConfig) getBaseConfig(); + } + + @Override + public void setStringList(String section, String subsection, String name, + List values) { + if (exists() || !parent.exists()) { + super.setStringList(section, subsection, name, values); + } else { + parent.setStringList(section, subsection, name, values); + } + } + + @Override + public void unset(String section, String subsection, String name) { + if (exists() || !parent.exists()) { + super.unset(section, subsection, name); + } else { + parent.unset(section, subsection, name); + } + } + + @Override + public void unsetSection(String section, String subsection) { + if (exists() || !parent.exists()) { + super.unsetSection(section, subsection); + } else { + parent.unsetSection(section, subsection); + } + } + + @Override + public boolean isOutdated() { + return super.isOutdated() || parent.isOutdated(); + } + + @Override + public void load() throws IOException, ConfigInvalidException { + if (super.isOutdated()) { + super.load(); + } + if (parent.isOutdated()) { + parent.load(); + } + } + + @Override + public void save() throws IOException { + if (exists() || !parent.exists()) { + if (exists() || !toText().strip().isEmpty()) { + super.save(); + } + } else { + parent.save(); + } + } +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/util/SystemReader.java b/org.eclipse.jgit/src/org/eclipse/jgit/util/SystemReader.java index 991de51df..4a4876271 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/util/SystemReader.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/util/SystemReader.java @@ -39,6 +39,7 @@ import org.eclipse.jgit.lib.ObjectChecker; import org.eclipse.jgit.lib.StoredConfig; import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.storage.file.UserConfigFile; import org.eclipse.jgit.util.time.MonotonicClock; import org.eclipse.jgit.util.time.MonotonicSystemClock; import org.slf4j.Logger; @@ -124,8 +125,15 @@ public boolean isOutdated() { @Override public FileBasedConfig openUserConfig(Config parent, FS fs) { - return new FileBasedConfig(parent, new File(fs.userHome(), ".gitconfig"), //$NON-NLS-1$ - fs); + File homeFile = new File(fs.userHome(), ".gitconfig"); //$NON-NLS-1$ + Path xdgPath = getXdgConfigDirectory(fs); + if (xdgPath != null) { + Path configPath = xdgPath.resolve("git") //$NON-NLS-1$ + .resolve(Constants.CONFIG); + return new UserConfigFile(parent, homeFile, configPath.toFile(), + fs); + } + return new FileBasedConfig(parent, homeFile, fs); } @Override