Handle global git config $XDG_CONFIG_HOME/git/config

C git uses this alternate fallback location if the file exists and
~/.gitconfig does not. Implement this also for JGit.

If both files exist, reading behavior is as if the XDG config was
inserted between the HOME config and the system config. Writing
behaviour is different: all changes will be applied only in the HOME
config. Updates will occur in the XDG config only if the HOME config
does not exist.

This is consistent with the behavior of C git; compare [1], especially
the sections on FILES and SCOPES, and the description of the --global
option.

[1] https://git-scm.com/docs/git-config

Bug: 581875
Change-Id: I2460b9aa963fd2811ed8a5b77b05107d916f2b44
Signed-off-by: Thomas Wolf <twolf@apache.org>
This commit is contained in:
Thomas Wolf 2023-07-05 22:21:30 +02:00 committed by Matthias Sohn
parent 0d5e017612
commit a2f326b762
6 changed files with 443 additions and 4 deletions

View File

@ -0,0 +1,301 @@
/*
* Copyright (C) 2023, Thomas Wolf <twolf@apache.org> 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"));
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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<String> values) {
super.setStringList(section, subsection, name, values);
}
@Override
public void unsetSection(String section, String subsection) {
super.unsetSection(section, subsection);
}
/**
* {@inheritDoc}
* <p>
@ -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) {

View File

@ -0,0 +1,110 @@
/*
* Copyright (C) 2023, Thomas Wolf <twolf@apache.org> 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}.
* <p>
* 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.
* </p>
*
* @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<String> 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();
}
}
}

View File

@ -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