Skip to content

Commit db01f09

Browse files
committed
feat(git): 凭据/署名三端共用 + 写操作据退出码如实上报
凭据注入从「UI 按钮 -c http.extraHeader 按命令注入」改为 git 原生 credential.helper=store:App 把 Room 凭据落盘到容器持久挂载 (/root/.aicode/git-credentials),buildContainerEnv 设 GIT_CONFIG_GLOBAL 指向持久 .gitconfig、provision 脚本写入 helper=store 并升 PROVISION_VERSION 到 v3 让老用户重 provision 自动补配。终端裸 git / AI ExecuteCommand / UI 按钮三端读同一份凭据,无需各自注入。 署名删 GitUserSettingsRepository(DataStore),以 git config 读写为唯一真源; 读 getUserName/Email 去 --global 按 local→global 自然解析(优先项目级、 无则退全局),写 setUserIdentity 优先 local、无则 global,UI 与命令行同源。 git 写操作(commit/stage/pull/push 等)改 gitChecked 据退出码判成败,非零 抛 GitCommandFailureException 携输出文本,runAction 据此如实显示「失败+ 原因」而非误报成功;无署名时提交按钮置灰并提示先配置 user.name。 GitScreen 接 CredentialPromptDialog 弹窗:pull/push 缺凭据时弹登录框, 填完存 Room + syncAll 落盘 + 自动重试。versionName 1.1.0 -> 1.2.0。
1 parent 9213220 commit db01f09

13 files changed

Lines changed: 432 additions & 128 deletions

File tree

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ android {
5757
// 数据目录里的文件,PRoot 二进制将无法运行(同 Termux 的取舍)。代价:不能上 Google Play。
5858
targetSdk = 28
5959
versionCode = gitCommitCount()
60-
versionName = "1.1.0"
60+
versionName = "1.2.0"
6161

6262
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
6363
vectorDrawables {

app/src/main/java/com/aicode/AIEditorApp.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.os.Build
77
import com.aicode.core.util.AILogger
88
import com.aicode.core.util.FileLogger
99
import com.aicode.feature.agent.domain.container.ContainerInstaller
10+
import com.aicode.feature.credentials.data.GitCredentialsFileSync
1011
import com.aicode.feature.agent.domain.mcp.McpManager
1112
import com.aicode.feature.settings.data.repository.KeepaliveSettingsRepository
1213
import com.aicode.feature.settings.data.repository.LogSettingsRepository
@@ -35,6 +36,11 @@ class AIEditorApp : Application() {
3536
@Inject
3637
lateinit var mcpManager: McpManager
3738

39+
/** git 凭据/署名落盘同步器:启动即把 Room 凭据 + DataStore 署名写到容器持久挂载目录,
40+
* 供终端/AI/UI 三端 git 经 credential.helper=store 共用,兜底 rootfs 升级或文件被删。 */
41+
@Inject
42+
lateinit var gitCredentialsFileSync: GitCredentialsFileSync
43+
3844
/** 长驻作用域:持续把持久化的日志等级同步到 FileLogger。 */
3945
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
4046

@@ -48,6 +54,11 @@ class AIEditorApp : Application() {
4854
appScope.launch {
4955
ContainerInstaller.extractDocs(this@AIEditorApp)
5056
}
57+
// 启动即把 Room 凭据 + DataStore 署名落盘到容器持久挂载(/root/.aicode),
58+
// 让终端裸 git / AI 工具 / UI 三端共用同一份凭据与署名配置。
59+
appScope.launch {
60+
gitCredentialsFileSync.syncAll()
61+
}
5162
// 启动即加载持久化等级,并随设置页改动实时生效(唯一同步点)。
5263
appScope.launch {
5364
logSettings.levelFlow.collectLatest { FileLogger.setMinLevel(it) }

app/src/main/java/com/aicode/feature/agent/domain/container/LinuxContainerEngine.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class LinuxContainerEngine @Inject constructor(
103103
* 独立于 [ContainerInstaller] 的 rootfs INSTALL_VERSION:rootfs 版本升级会删 rootfs
104104
* (连带清掉本标记),故新 rootfs 必然重跑配置;同 rootfs 下改包清单则靠本版本号触发。
105105
*/
106-
private const val PROVISION_VERSION = "py3.12-pip-node-bash-curl-rg-v2"
106+
private const val PROVISION_VERSION = "py3.12-pip-node-bash-curl-rg-gitcredhelper-v3"
107107

108108
/** `apk add` 下载基础包的超时(毫秒):首次配置需联网拉包,给足时间。 */
109109
private const val PROVISION_TIMEOUT_MS = 600_000L
@@ -209,6 +209,20 @@ class LinuxContainerEngine @Inject constructor(
209209
execCaptured(command, projectPath, timeoutMs).output
210210
}
211211

212+
/**
213+
* 同 [runCommandSync],但一并返回退出码(超时/异常时为 null)。供需要据退出码判成败的调用方
214+
* 使用——如 git 写操作:git 非零退出码并非进程崩溃,[runCommandSync] 仅返回文本会让上层误报成功。
215+
*/
216+
suspend fun runCommandSyncWithExit(
217+
command: String,
218+
projectPath: String? = null,
219+
timeoutMs: Long = DEFAULT_TIMEOUT_MS
220+
): CommandResult = withContext(Dispatchers.IO) {
221+
ensureInstalled()
222+
val r = execCaptured(command, projectPath, timeoutMs)
223+
CommandResult(r.output, r.exitCode)
224+
}
225+
212226
/** 一次容器内执行的结果:限幅后的完整输出 + 退出码(超时/异常时为 null)。 */
213227
data class CommandResult(val output: String, val exitCode: Int?)
214228

@@ -325,6 +339,9 @@ class LinuxContainerEngine @Inject constructor(
325339
append("EOF\n")
326340
append("apk update\n")
327341
append("apk add --no-cache $PROVISION_PACKAGES\n")
342+
// 装好 git 后配置全局 credential.helper,指向持久挂载的凭据文件,让终端/AI/UI 三端裸 git
343+
// 自动带凭据(GIT_CONFIG_GLOBAL 已指 /root/.aicode/.gitconfig,写入不随升级丢失)。
344+
append("git config --global credential.helper 'store --file=/root/.aicode/git-credentials'\n")
328345
}
329346

330347
var exitCode: Int? = null
@@ -554,6 +571,10 @@ class LinuxContainerEngine @Inject constructor(
554571
// (PROOT_NO_SECCOMP=1) 反而在本设备触发过 ptrace(PEEKDATA) I/O error。
555572
"PATH" to "/usr/bin:/bin:/usr/sbin:/sbin",
556573
"HOME" to "/root",
574+
// git 全局配置指向持久挂载里的 .gitconfig(/root/.aicode 绑定到宿主 filesDir/aicode,
575+
// 跨 rootfs 升级不丢)。git-credentials 同放该目录,credential.helper=store 经此读。
576+
// 让终端/AI/UI 三端 git 都读同一份配置与凭据,详见 GitCredentialsFileSync。
577+
"GIT_CONFIG_GLOBAL" to "/root/.aicode/.gitconfig",
557578
"TERM" to "xterm-256color",
558579
"LANG" to "C.UTF-8"
559580
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.aicode.feature.credentials.data
2+
3+
import android.content.Context
4+
import com.aicode.core.util.FileLogger
5+
import com.aicode.feature.credentials.domain.model.GitCredential
6+
import com.aicode.feature.credentials.domain.repository.CredentialRepository
7+
import dagger.hilt.android.qualifiers.ApplicationContext
8+
import kotlinx.coroutines.flow.first
9+
import java.io.File
10+
import java.net.URLEncoder
11+
import javax.inject.Inject
12+
import javax.inject.Singleton
13+
14+
/**
15+
* 把 Room 凭据落盘到容器持久挂载目录,让 UI / 终端 / AI 三端 git 经 `credential.helper=store` 共用同一份凭据。
16+
*
17+
* 宿主目录 `filesDir/aicode` 即容器内 `/root/.aicode`([LinuxContainerEngine] 的 `-b` 绑定,跨 rootfs 升级不丢)。
18+
* 容器 [buildContainerEnv] 已设 `GIT_CONFIG_GLOBAL=/root/.aicode/.gitconfig`,`.gitconfig` 里的
19+
* `[credential] helper = store --file=/root/.aicode/git-credentials` 由 [provisionIfNeeded] 装容器时
20+
* 跑一次 `git config --global` 写入(git 自身增量维护 ini)。
21+
*
22+
* 本类只负责**凭据文件** `git-credentials`(每 host 默认条一行),直接宿主侧写盘,不进容器、不跑 git 命令。
23+
* `.gitconfig` 不由本类写——它由 git 命令维护:`[credential]` 段在容器初始化时写、`[user]` 署名段由用户经
24+
* UI `git config --global user.name/email` 增量写。这样署名真源就是 `.gitconfig` 本身,UI 与命令行共用同一份,
25+
* 不存在两套写入路径竞争。
26+
*
27+
* 调用方:[AIEditorApp] 启动兜底、[CredentialViewModel] 增删改后、[GitViewModel] 弹窗保存后。
28+
*/
29+
@Singleton
30+
class GitCredentialsFileSync @Inject constructor(
31+
@param:ApplicationContext private val context: Context,
32+
private val credentialRepository: CredentialRepository
33+
) {
34+
private companion object {
35+
const val TAG = "GitCredentialsFileSync"
36+
/** 容器内 `.aicode` 挂载点对应的宿主目录(即文件实际落盘处)。 */
37+
const val AICODE_DIR_NAME = "aicode"
38+
const val CREDENTIALS_NAME = "git-credentials"
39+
}
40+
41+
/** 目标宿主目录(容器内 /root/.aicode 的物理后端)。懒建。 */
42+
private val aicodeDir: File get() = File(context.filesDir, AICODE_DIR_NAME).apply { mkdirs() }
43+
44+
/** 全量重建凭据文件。幂等、可重复调用。凭据为空时写空文件(不删文件,让 git 知无凭据)。 */
45+
suspend fun syncAll() {
46+
runCatching {
47+
val creds = credentialRepository.getAll().first()
48+
writeCredentials(creds)
49+
}.onFailure { FileLogger.e(TAG, "同步 git 凭据落盘失败", it) }
50+
}
51+
52+
/** 写 `git-credentials`:每 host 默认条一行,按 git-credential-store 格式;空凭据写空文件。 */
53+
private fun writeCredentials(creds: List<GitCredential>) {
54+
// 每 host 取默认条(isDefault 优先),无默认回退该 host 首条——与 findForHost 同语义。
55+
val byHost: Map<String, GitCredential> = creds
56+
.filter { it.host.isNotBlank() && it.username.isNotBlank() }
57+
.sortedWith(compareByDescending<GitCredential> { it.isDefault }.thenBy { it.host })
58+
.associateBy { it.host.trim().lowercase() }
59+
60+
val sb = StringBuilder()
61+
byHost.values.sortedBy { it.host }.forEach { c ->
62+
sb.append("https://")
63+
.append(enc(c.username)).append(':').append(enc(c.token))
64+
.append('@').append(c.host.trim().lowercase())
65+
.append('\n')
66+
}
67+
writeAtomically(File(aicodeDir, CREDENTIALS_NAME), sb.toString())
68+
}
69+
70+
/** 先写 `.tmp` 再 rename,避免容器侧/git 读到半截文件。 */
71+
private fun writeAtomically(target: File, content: String) {
72+
val tmp = File(target.parentFile, "${target.name}.tmp")
73+
tmp.writeText(content)
74+
if (target.exists()) target.delete()
75+
if (!tmp.renameTo(target)) {
76+
// 兜底:rename 失败(某些 FS 跨 inode)则直接写目标。
77+
target.writeText(content)
78+
tmp.delete()
79+
}
80+
}
81+
82+
private fun enc(part: String): String = URLEncoder.encode(part, "UTF-8")
83+
}

app/src/main/java/com/aicode/feature/credentials/data/GitUserSettingsRepository.kt

Lines changed: 0 additions & 43 deletions
This file was deleted.

app/src/main/java/com/aicode/feature/credentials/domain/model/GitCredential.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.aicode.feature.credentials.domain.model
22

3+
import kotlin.random.Random
4+
35
/** 一条 Git 远程仓库凭据的领域模型。无 Android 依赖。 */
46
data class GitCredential(
57
val id: String,
@@ -11,3 +13,7 @@ data class GitCredential(
1113
val createdAt: Long = 0,
1214
val updatedAt: Long = 0
1315
)
16+
17+
/** 生成新凭据 id:时间戳 + 随机后缀(避免同毫秒冲突),供凭据编辑页与 git 拉取/推送缺凭据弹窗共用。 */
18+
fun newCredentialId(): String = "${System.currentTimeMillis()}${Random.nextInt(1000, 9999)}"
19+

app/src/main/java/com/aicode/feature/credentials/presentation/CredentialViewModel.kt

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,85 +3,92 @@ package com.aicode.feature.credentials.presentation
33
import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.aicode.core.util.FileLogger
6-
import com.aicode.feature.credentials.data.GitUserSettingsRepository
6+
import com.aicode.feature.credentials.data.GitCredentialsFileSync
77
import com.aicode.feature.credentials.domain.model.GitCredential
88
import com.aicode.feature.credentials.domain.repository.CredentialRepository
99
import com.aicode.feature.git.domain.GitRepository
1010
import dagger.hilt.android.lifecycle.HiltViewModel
1111
import kotlinx.coroutines.flow.MutableStateFlow
1212
import kotlinx.coroutines.flow.SharingStarted
1313
import kotlinx.coroutines.flow.StateFlow
14-
import kotlinx.coroutines.flow.asStateFlow
1514
import kotlinx.coroutines.flow.combine
16-
import kotlinx.coroutines.flow.first
1715
import kotlinx.coroutines.flow.stateIn
1816
import kotlinx.coroutines.flow.update
1917
import kotlinx.coroutines.launch
2018
import javax.inject.Inject
21-
import kotlin.random.Random
2219

2320
/**
2421
* 凭据页 UI 编排:凭据 CRUD + 提交署名(user.name/email) 配置。
2522
*
26-
* 提交署名以 [GitUserSettingsRepository](DataStore) 为真源;用户保存后同步落 `git config --global`
27-
* ([GitRepository.setUserGlobal])。进页 init 时若 DataStore 有值则补一次同步,保证 rootfs 升级丢
28-
* `.gitconfig` 后能自动恢复——单向「DataStore → git」,DataStore 空时不动容器既有配置
23+
* 提交署名读写走 `git config`([GitRepository.setUserIdentity] / [getUserName] / [getUserEmail]):
24+
* **优先项目级**(当前工作区 /workspace/.git/config),无则退全局(持久挂载 /root/.aicode/.gitconfig)。
25+
* UI 与终端敲 `git config user.name` 读到的是同一份署名——优先项目级、无则退全局,无两套源头竞争
2926
*/
3027
@HiltViewModel
3128
class CredentialViewModel @Inject constructor(
3229
private val credentialRepository: CredentialRepository,
33-
private val userSettings: GitUserSettingsRepository,
34-
private val gitRepository: GitRepository
30+
private val gitRepository: GitRepository,
31+
private val fileSync: GitCredentialsFileSync
3532
) : ViewModel() {
3633

3734
private companion object { const val TAG = "CredentialViewModel" }
3835

3936
data class UiState(
4037
val credentials: List<GitCredential> = emptyList(),
38+
/** 容器 git config 当前 user.name,作编辑框初值。 */
4139
val userName: String = "",
40+
/** 容器 git config 当前 user.email,作编辑框初值。 */
4241
val userEmail: String = "",
43-
/** 容器内 `git config --global` 实际的 user.name,用于回显「git 实际值」。 */
42+
/** 容器 `git config --global` 实际 user.name,回显「git 实际值」(与 userName 同源)*/
4443
val globalUserName: String = "",
4544
val toast: String? = null
4645
)
4746

48-
// combine 持久流外,单独用一个 MutableStateFlow 承载「全局实际值」与 toast 这类非 DataStore 来源的状态
47+
// 非反应式来源的状态:git config 读出的署名、toast。credentials 走 Room Flow
4948
private val _extra = MutableStateFlow(Extra())
50-
private data class Extra(val globalUserName: String = "", val toast: String? = null)
49+
private data class Extra(
50+
val userName: String = "",
51+
val userEmail: String = "",
52+
val globalUserName: String = "",
53+
val toast: String? = null
54+
)
5155

52-
val state: StateFlow<UiState> = combine(
53-
credentialRepository.getAll(),
54-
userSettings.userNameFlow,
55-
userSettings.userEmailFlow,
56-
_extra
57-
) { creds, name, email, extra ->
56+
val state: StateFlow<UiState> = combine(credentialRepository.getAll(), _extra) { creds, extra ->
5857
UiState(
5958
credentials = creds,
60-
userName = name,
61-
userEmail = email,
59+
userName = extra.userName,
60+
userEmail = extra.userEmail,
6261
globalUserName = extra.globalUserName,
6362
toast = extra.toast
6463
)
6564
}.stateIn(viewModelScope, SharingStarted.Eagerly, UiState())
6665

67-
init { syncGlobalIdentity() }
66+
init {
67+
refreshIdentity()
68+
// 兜底:把 Room 凭据落盘到容器持久挂载,保证升级后或文件被删时仍就位(详见 GitCredentialsFileSync)。
69+
viewModelScope.launch { fileSync.syncAll() }
70+
}
6871

69-
/** 把 DataStore 的署名(若非空)同步进容器 git 全局配置,并刷新实际值回显*/
70-
private fun syncGlobalIdentity() {
72+
/** 从容器 git config 读取当前署名刷新 UI(编辑框初值 + 实际值回显)。可重入,进凭据页时调一次兜住终端改动*/
73+
fun refreshIdentity() {
7174
viewModelScope.launch {
7275
runCatching {
73-
val name = userSettings.userNameFlow.first()
74-
val email = userSettings.userEmailFlow.first()
75-
if (name.isNotBlank() || email.isNotBlank()) gitRepository.setUserGlobal(name, email)
76-
_extra.update { it.copy(globalUserName = gitRepository.getGlobalUserName()) }
77-
}.onFailure { FileLogger.w(TAG, "同步全局署名失败: ${it.message}") }
76+
_extra.update {
77+
it.copy(
78+
userName = gitRepository.getUserName(),
79+
userEmail = gitRepository.getUserEmail(),
80+
globalUserName = gitRepository.getUserName()
81+
)
82+
}
83+
}.onFailure { FileLogger.w(TAG, "读取 git 全局署名失败: ${it.message}") }
7884
}
7985
}
8086

8187
fun saveCredential(credential: GitCredential) {
8288
viewModelScope.launch {
8389
try {
8490
credentialRepository.save(credential)
91+
fileSync.syncAll() // 落盘到容器持久挂载,UI/终端/AI 三端共用
8592
toast("凭据已保存")
8693
} catch (e: Exception) {
8794
FileLogger.e(TAG, "保存凭据失败", e)
@@ -93,21 +100,24 @@ class CredentialViewModel @Inject constructor(
93100
fun deleteCredential(id: String) {
94101
viewModelScope.launch {
95102
credentialRepository.delete(id)
103+
fileSync.syncAll() // 删凭据后从落盘文件移除,否则终端/AI 仍能用旧凭据
96104
toast("凭据已删除")
97105
}
98106
}
99107

100108
fun setDefault(id: String, isDefault: Boolean) {
101-
viewModelScope.launch { credentialRepository.setDefault(id, isDefault) }
109+
viewModelScope.launch {
110+
credentialRepository.setDefault(id, isDefault)
111+
fileSync.syncAll() // 切默认后落盘文件随之更新,三端按新默认条带凭据
112+
}
102113
}
103114

104-
/** 保存提交署名:写 DataStore 真源 + 同步落 git 全局配置 + 刷新实际值回显*/
115+
/** 保存提交署名:跑 `git config --global` 写入 .gitconfig 真源 + 刷新回显。UI 与命令行同一文件*/
105116
fun saveUserIdentity(name: String, email: String) {
106117
viewModelScope.launch {
107118
try {
108-
userSettings.setUserIdentity(name, email)
109-
gitRepository.setUserGlobal(name, email)
110-
_extra.update { it.copy(globalUserName = gitRepository.getGlobalUserName()) }
119+
gitRepository.setUserIdentity(name, email)
120+
refreshIdentity()
111121
toast("署名已保存到 git 全局配置")
112122
} catch (e: Exception) {
113123
FileLogger.e(TAG, "保存署名失败", e)
@@ -120,6 +130,3 @@ class CredentialViewModel @Inject constructor(
120130

121131
private fun toast(msg: String) = _extra.update { it.copy(toast = msg) }
122132
}
123-
124-
/** 生成新凭据 id:时间戳 + 随机后缀(避免同毫秒冲突),仿现有 entity 主键风格。 */
125-
internal fun newCredentialId(): String = "${System.currentTimeMillis()}${Random.nextInt(1000, 9999)}"

0 commit comments

Comments
 (0)