Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
155 linesCodeBlameRaw
1//! The CLI's settings: the hosts it is signed in to, which account is active
2//! on each, and aliases. One JSON file, `config.json`, in
3//!
4//! - `G1T_CONFIG_DIR`, when set;
5//! - `%APPDATA%\g1t` on Windows;
6//! - `$XDG_CONFIG_HOME/g1t`, or `~/.config/g1t`, elsewhere.
7//!
8//! Tokens are not kept here unless the system keychain cannot hold them
9//! (see [`crate::auth`]); then the file is written readable by its owner only.
10
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use crate::error::{Error, Result};
16
17pub const DEFAULT_HOST: &str = "g1t.sh";
18
19#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
20pub struct Config {
21 /// The host commands talk to when nothing else names one.
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub default_host: Option<String>,
24 #[serde(default)]
25 pub hosts: BTreeMap<String, HostConfig>,
26 #[serde(default)]
27 pub aliases: BTreeMap<String, String>,
28}
29
30#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
31pub struct HostConfig {
32 /// The site, like https://g1t.sh: git remotes and pages.
33 pub site: String,
34 /// The REST API, like https://api.g1t.sh.
35 pub api: String,
36 /// The active account's username.
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub user: Option<String>,
39 #[serde(default)]
40 pub users: BTreeMap<String, UserConfig>,
41}
42
43#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
44pub struct UserConfig {
45 /// Set only when the token could not go in the system keychain.
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub token: Option<String>,
48}
49
50pub fn dir() -> Result<PathBuf> {
51 if let Some(dir) = std::env::var_os("G1T_CONFIG_DIR").filter(|d| !d.is_empty()) {
52 return Ok(PathBuf::from(dir));
53 }
54 if cfg!(windows)
55 && let Some(appdata) = std::env::var_os("APPDATA")
56 {
57 return Ok(PathBuf::from(appdata).join("g1t"));
58 }
59 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) {
60 return Ok(PathBuf::from(xdg).join("g1t"));
61 }
62 let home = std::env::var_os("HOME")
63 .or_else(|| std::env::var_os("USERPROFILE"))
64 .ok_or_else(|| {
65 Error::failed("No home directory to keep settings in. Set G1T_CONFIG_DIR.")
66 })?;
67 Ok(PathBuf::from(home).join(".config").join("g1t"))
68}
69
70pub fn path() -> Result<PathBuf> {
71 Ok(dir()?.join("config.json"))
72}
73
74impl Config {
75 pub fn load() -> Result<Config> {
76 let path = path()?;
77 match std::fs::read_to_string(&path) {
78 Ok(text) if text.trim().is_empty() => Ok(Config::default()),
79 Ok(text) => serde_json::from_str(&text)
80 .map_err(|e| Error::failed(format!("{} is not valid: {e}", path.display()))),
81 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
82 Err(e) => Err(Error::failed(format!(
83 "Could not read {}: {e}",
84 path.display()
85 ))),
86 }
87 }
88
89 pub fn save(&self) -> Result<()> {
90 let path = path()?;
91 if let Some(parent) = path.parent() {
92 std::fs::create_dir_all(parent)
93 .map_err(|e| Error::failed(format!("Could not make {}: {e}", parent.display())))?;
94 }
95 let text = serde_json::to_string_pretty(self).expect("config serializes") + "\n";
96 let tmp = path.with_extension("json.tmp");
97 write_private(&tmp, text.as_bytes())
98 .map_err(|e| Error::failed(format!("Could not write {}: {e}", tmp.display())))?;
99 std::fs::rename(&tmp, &path)
100 .map_err(|e| Error::failed(format!("Could not write {}: {e}", path.display())))?;
101 Ok(())
102 }
103
104 pub fn host(&self, host: &str) -> Option<&HostConfig> {
105 self.hosts.get(host)
106 }
107}
108
109/// Writes a file only its owner can read: it may hold a token.
110fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
111 #[cfg(unix)]
112 {
113 use std::io::Write;
114 use std::os::unix::fs::OpenOptionsExt;
115 let mut file = std::fs::OpenOptions::new()
116 .write(true)
117 .create(true)
118 .truncate(true)
119 .mode(0o600)
120 .open(path)?;
121 file.write_all(bytes)
122 }
123 #[cfg(not(unix))]
124 {
125 std::fs::write(path, bytes)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn an_empty_config_round_trips() {
135 let config: Config = serde_json::from_str("{}").unwrap();
136 assert_eq!(config, Config::default());
137 let mut config = Config::default();
138 config.hosts.insert(
139 "g1t.sh".into(),
140 HostConfig {
141 site: "https://g1t.sh".into(),
142 api: "https://api.g1t.sh".into(),
143 user: Some("ana".into()),
144 users: [("ana".to_owned(), UserConfig::default())].into(),
145 },
146 );
147 config.aliases.insert("co".into(), "pr checkout".into());
148 let text = serde_json::to_string(&config).unwrap();
149 assert!(
150 !text.contains("token"),
151 "no token field when none is kept: {text}"
152 );
153 assert_eq!(serde_json::from_str::<Config>(&text).unwrap(), config);
154 }
155}