Line data Source code
1 : /*!
2 : * \file esys/repo/gitcmdline/git_cmdline.cpp
3 : * \brief System ``git`` CLI backend (Phase-1 esysrepo surface)
4 : *
5 : * \cond
6 : * __legal_b__
7 : *
8 : * Copyright (c) 2020-2026 Michel Gillet
9 : * Distributed under the MIT License.
10 : * (See accompanying file LICENSE.txt or
11 : * copy at https://opensource.org/licenses/MIT)
12 : *
13 : * __legal_e__
14 : * \endcond
15 : *
16 : */
17 :
18 : #include "esys/repo/esysrepo_prec.h"
19 : #include "esys/repo/gitcmdline/git.h"
20 :
21 : #include "esys/repo/git/aheadbehind.h"
22 : #include "esys/repo/git/branch.h"
23 : #include "esys/repo/git/commit.h"
24 : #include "esys/repo/git/commithash.h"
25 : #include "esys/repo/git/remote.h"
26 : #include "esys/repo/git/repostatus.h"
27 : #include "esys/repo/git/resettype.h"
28 : #include "esys/repo/git/status.h"
29 : #include "esys/repo/git/statussubtype.h"
30 : #include "esys/repo/git/statustype.h"
31 :
32 : #include <boost/filesystem.hpp>
33 : #include <boost/process/search_path.hpp>
34 :
35 : #include <chrono>
36 : #include <cstdio>
37 : #include <cstdlib>
38 : #include <ctime>
39 : #include <map>
40 : #include <memory>
41 : #include <sstream>
42 : #include <string>
43 : #include <vector>
44 :
45 : #ifdef _WIN32
46 : #define ESYSREPO_POPEN _popen
47 : #define ESYSREPO_PCLOSE _pclose
48 : #else
49 : #define ESYSREPO_POPEN popen
50 : #define ESYSREPO_PCLOSE pclose
51 : #endif
52 :
53 : namespace esys::repo::gitcmdline
54 : {
55 :
56 : namespace
57 : {
58 :
59 1355 : std::string shell_quote(const std::string &arg)
60 : {
61 : #ifdef _WIN32
62 : // cmd.exe: double % (env expansion) and quote when spaces or shell metacharacters
63 : // appear (notably '|' in git for-each-ref --format).
64 : std::string escaped;
65 : escaped.reserve(arg.size() * 2);
66 : for (char c : arg)
67 : {
68 : if (c == '%') escaped += '%';
69 : escaped += c;
70 : }
71 : const bool need_quote = escaped.find_first_of(" \t\"|&<>^!") != std::string::npos;
72 : if (!need_quote) return escaped;
73 : std::string out = "\"";
74 : for (char c : escaped)
75 : {
76 : // cmd embedded quote is ""
77 : if (c == '"') out += '"';
78 : out += c;
79 : }
80 : out += '"';
81 : return out;
82 : #else
83 1355 : if (arg.find_first_of(" \t\"'\\") == std::string::npos) return arg;
84 0 : std::string out = "\"";
85 0 : for (char c : arg)
86 : {
87 0 : if (c == '"') out += '\\';
88 0 : out += c;
89 : }
90 0 : out += '"';
91 0 : return out;
92 : #endif
93 1355 : }
94 :
95 41 : std::string trim(std::string s)
96 : {
97 71 : while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ' || s.back() == '\t')) s.pop_back();
98 : std::size_t i = 0;
99 43 : while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i;
100 41 : return s.substr(i);
101 : }
102 :
103 2 : std::string strip_rev_prefix(std::string rev)
104 : {
105 2 : const std::string heads = "refs/heads/";
106 2 : const std::string tags = "refs/tags/";
107 2 : if (rev.compare(0, heads.size(), heads) == 0) return rev.substr(heads.size());
108 2 : if (rev.compare(0, tags.size(), tags) == 0) return rev.substr(tags.size());
109 4 : return rev;
110 2 : }
111 :
112 7 : std::vector<std::string> split_lines(const std::string &text)
113 : {
114 7 : std::vector<std::string> lines;
115 7 : std::string cur;
116 745 : for (char c : text)
117 : {
118 738 : if (c == '\n')
119 : {
120 14 : if (!cur.empty() && cur.back() == '\r') cur.pop_back();
121 14 : lines.push_back(cur);
122 14 : cur.clear();
123 : }
124 : else
125 1462 : cur += c;
126 : }
127 7 : if (!cur.empty())
128 : {
129 0 : if (cur.back() == '\r') cur.pop_back();
130 0 : lines.push_back(cur);
131 : }
132 7 : return lines;
133 7 : }
134 :
135 1 : git::StatusSubType porcelain_to_subtype(char code)
136 : {
137 1 : switch (code)
138 : {
139 : case 'A': return git::StatusSubType::NEW;
140 : case 'M': return git::StatusSubType::MODIFIED;
141 : case 'D': return git::StatusSubType::DELETED;
142 : case 'R': return git::StatusSubType::RENAMED;
143 : case 'C': return git::StatusSubType::NEW;
144 : case 'T': return git::StatusSubType::TYPECHANGE;
145 : case '?': return git::StatusSubType::NEW;
146 : default: return git::StatusSubType::NOT_SET;
147 : }
148 : }
149 :
150 1 : git::DiffDeltaType porcelain_to_delta_type(char code, bool worktree)
151 : {
152 1 : switch (code)
153 : {
154 : case 'A': return git::DiffDeltaType::ADDED;
155 1 : case 'M': return git::DiffDeltaType::MODIFIED;
156 0 : case 'D': return git::DiffDeltaType::DELETED;
157 0 : case 'R': return git::DiffDeltaType::RENAMED;
158 0 : case 'C': return git::DiffDeltaType::COPIED;
159 0 : case 'T': return git::DiffDeltaType::TYPECHANGE;
160 0 : case '?': return worktree ? git::DiffDeltaType::UNTRACKED : git::DiffDeltaType::ADDED;
161 0 : case '!': return git::DiffDeltaType::IGNORED;
162 0 : case 'U': return git::DiffDeltaType::CONFLICTED;
163 0 : default: return git::DiffDeltaType::NOT_SET;
164 : }
165 : }
166 :
167 2 : void fill_status_paths(git::Status &status, const std::string &old_path, const std::string &new_path,
168 : git::DiffDeltaType delta_type)
169 : {
170 2 : auto &delta = status.get_diff_delta();
171 2 : delta.set_type(delta_type);
172 2 : delta.get_old_file().set_path(old_path);
173 4 : delta.get_new_file().set_path(new_path.empty() ? old_path : new_path);
174 2 : delta.set_file_count(old_path == new_path || new_path.empty() ? 1 : 2);
175 2 : }
176 :
177 2 : bool parse_porcelain_paths(const std::string &rest, std::string &old_path, std::string &new_path)
178 : {
179 : // XY<path> or XY <ORIG> -> <PATH> (rename/copy). Optional leading space after XY.
180 2 : std::string path_part = rest;
181 2 : if (!path_part.empty() && path_part[0] == ' ') path_part.erase(0, 1);
182 :
183 2 : const std::string arrow = " -> ";
184 2 : const auto arrow_pos = path_part.find(arrow);
185 2 : if (arrow_pos != std::string::npos)
186 : {
187 0 : old_path = path_part.substr(0, arrow_pos);
188 0 : new_path = path_part.substr(arrow_pos + arrow.size());
189 0 : return !old_path.empty() && !new_path.empty();
190 : }
191 :
192 2 : old_path = path_part;
193 2 : new_path = path_part;
194 2 : return !old_path.empty();
195 2 : }
196 :
197 2 : void add_porcelain_status(git::RepoStatus &repo_status, git::StatusType type, git::StatusSubType sub_type,
198 : git::DiffDeltaType delta_type, const std::string &old_path, const std::string &new_path)
199 : {
200 2 : auto status = std::make_shared<git::Status>();
201 2 : status->set_type(type);
202 2 : status->set_sub_type(sub_type);
203 : // RepoStatus::add keys the file map by old_file path — always set it.
204 2 : fill_status_paths(*status, old_path, new_path, delta_type);
205 6 : repo_status.add(status);
206 2 : }
207 :
208 : } // namespace
209 :
210 : bool Git::s_detect_ssh_agent_done = false;
211 : bool Git::s_ssh_agent_running = false;
212 :
213 0 : std::shared_ptr<GitBase> Git::new_ptr()
214 : {
215 0 : return std::make_shared<Git>();
216 : }
217 :
218 155 : Git::Git()
219 155 : : GitBase()
220 : {
221 155 : }
222 :
223 155 : Git::~Git()
224 : {
225 155 : if (m_open) close();
226 155 : }
227 :
228 455 : std::string Git::find_git_executable()
229 : {
230 455 : if (const char *env = std::getenv("ESYSREPO_GIT_EXE"))
231 : {
232 0 : if (env[0] != '\0' && boost::filesystem::exists(env)) return std::string(env);
233 : }
234 455 : if (const char *env = std::getenv("GIT_EXECUTABLE"))
235 : {
236 0 : if (env[0] != '\0' && boost::filesystem::exists(env)) return std::string(env);
237 : }
238 910 : boost::filesystem::path found = boost::process::search_path("git");
239 : // Prefer the bare name so std::system/cmd.exe does not break on spaces in
240 : // "C:\Program Files\Git\..." (PATH already contains the directory).
241 455 : if (!found.empty()) return "git";
242 455 : return {};
243 455 : }
244 :
245 0 : Result Git::not_implemented() const
246 : {
247 0 : return ESYSREPO_RESULT(ResultCode::NOT_IMPLEMENTED);
248 : }
249 :
250 223 : std::string Git::build_git_command(const std::vector<std::string> &args, const std::string &cwd) const
251 : {
252 223 : const std::string git = find_git_executable();
253 223 : std::ostringstream cmd;
254 446 : cmd << shell_quote(git);
255 303 : if (!cwd.empty()) cmd << " -C " << shell_quote(cwd);
256 1275 : for (const auto &arg : args) cmd << ' ' << shell_quote(arg);
257 223 : return cmd.str();
258 223 : }
259 :
260 4 : Result Git::run_git(const std::vector<std::string> &args, const std::string &cwd)
261 : {
262 4 : const std::string git = find_git_executable();
263 4 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
264 :
265 4 : const std::string cmd = build_git_command(args, cwd);
266 8 : debug(1, std::string("[gitcmdline] ") + cmd);
267 4 : cmd_start();
268 4 : const int rc = std::system(cmd.c_str());
269 4 : cmd_end();
270 4 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
271 4 : return ESYSREPO_RESULT(ResultCode::OK);
272 4 : }
273 :
274 829 : void Git::feed_progress_stderr(std::string &pending, const char *data, std::size_t len)
275 : {
276 829 : pending.append(data, len);
277 829 : std::size_t start = 0;
278 771647 : for (std::size_t i = 0; i < pending.size(); ++i)
279 : {
280 770818 : const char c = pending[i];
281 770818 : if (c != '\r' && c != '\n') continue;
282 35050 : if (i > start) handle_sideband_progress(pending.substr(start, i - start));
283 17525 : start = i + 1;
284 : }
285 829 : if (start > 0) pending.erase(0, start);
286 829 : }
287 :
288 183 : Result Git::run_git_progress(const std::vector<std::string> &args, const std::string &cwd)
289 : {
290 183 : const std::string git = find_git_executable();
291 183 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
292 :
293 : // Merge stderr into the pipe so ``--progress`` lines (CR-updated) are readable.
294 183 : std::string cmd = build_git_command(args, cwd);
295 183 : cmd += " 2>&1";
296 :
297 366 : debug(1, std::string("[gitcmdline] ") + cmd);
298 183 : cmd_start();
299 183 : FILE *pipe = ESYSREPO_POPEN(cmd.c_str(), "r");
300 183 : if (pipe == nullptr)
301 : {
302 0 : cmd_end();
303 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to start git");
304 : }
305 :
306 183 : std::string pending;
307 864 : char buf[1024];
308 864 : while (true)
309 : {
310 864 : const std::size_t n = std::fread(buf, 1, sizeof(buf), pipe);
311 864 : if (n > 0) feed_progress_stderr(pending, buf, n);
312 864 : if (n < sizeof(buf))
313 : {
314 183 : if (std::feof(pipe)) break;
315 0 : if (std::ferror(pipe)) break;
316 : }
317 : }
318 183 : if (!pending.empty()) handle_sideband_progress(pending);
319 :
320 183 : const int rc = ESYSREPO_PCLOSE(pipe);
321 183 : cmd_end();
322 :
323 183 : git::Progress done;
324 183 : done.set_done(true);
325 183 : done.set_percentage(git::Progress::MAX_PERCENTAGE);
326 183 : handle_transfer_progress(done);
327 :
328 183 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
329 168 : return ESYSREPO_RESULT(ResultCode::OK);
330 366 : }
331 :
332 36 : Result Git::run_git_out(const std::vector<std::string> &args, const std::string &cwd, std::string &output)
333 : {
334 36 : output.clear();
335 36 : const std::string git = find_git_executable();
336 36 : if (git.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "git executable not found");
337 :
338 : // Capture stdout; discard stderr so porcelain/status noise does not pollute parsing.
339 36 : std::string cmd = build_git_command(args, cwd);
340 : #ifdef _WIN32
341 : cmd += " 2>NUL";
342 : #else
343 36 : cmd += " 2>/dev/null";
344 : #endif
345 :
346 72 : debug(1, std::string("[gitcmdline] ") + cmd);
347 36 : cmd_start();
348 36 : FILE *pipe = ESYSREPO_POPEN(cmd.c_str(), "r");
349 36 : if (pipe == nullptr)
350 : {
351 0 : cmd_end();
352 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to start git");
353 : }
354 : char buf[4096];
355 78 : while (std::fgets(buf, sizeof(buf), pipe) != nullptr) output += buf;
356 36 : const int rc = ESYSREPO_PCLOSE(pipe);
357 36 : cmd_end();
358 36 : if (rc != 0) return ESYSREPO_RESULT(ResultCode::RAW_INT_ERROR, rc, cmd);
359 36 : return ESYSREPO_RESULT(ResultCode::OK);
360 36 : }
361 :
362 221 : Result Git::open(const std::string &folder)
363 : {
364 221 : if (!is_repo(folder)) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "not a git repository");
365 221 : m_folder = folder;
366 221 : m_open = true;
367 221 : open_time();
368 221 : return ESYSREPO_RESULT(ResultCode::OK);
369 : }
370 :
371 600 : bool Git::is_open()
372 : {
373 600 : return m_open;
374 : }
375 :
376 221 : Result Git::close()
377 : {
378 221 : m_open = false;
379 221 : m_folder.clear();
380 221 : close_time();
381 221 : return ESYSREPO_RESULT(ResultCode::OK);
382 : }
383 :
384 0 : void Git::close_on_error()
385 : {
386 0 : if (m_open) close();
387 0 : }
388 :
389 143 : Result Git::clone(const std::string &url, const std::string &path, const std::string &rev,
390 : const git::CloneOptions &options)
391 : {
392 143 : if (url.empty() || path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty url or path");
393 :
394 429 : std::vector<std::string> args = {"clone", "--progress"};
395 : // Default CloneOptions: ignore rev (legacy remote-default tip only).
396 143 : if (options.checkout_rev && !rev.empty())
397 : {
398 97 : args.emplace_back("--branch");
399 97 : args.push_back(rev);
400 : }
401 143 : if (options.checkout_rev && options.single_branch) args.emplace_back("--single-branch");
402 143 : args.push_back(url);
403 143 : args.push_back(path);
404 :
405 143 : auto result = run_git_progress(args);
406 158 : if (result.error()) return ESYSREPO_RESULT(result);
407 :
408 128 : return open(path);
409 143 : }
410 :
411 42 : Result Git::fetch(const std::string &remote)
412 : {
413 42 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
414 :
415 120 : std::vector<std::string> args = {"fetch", "--progress"};
416 40 : if (!remote.empty()) args.push_back(remote);
417 40 : return run_git_progress(args, m_folder);
418 40 : }
419 :
420 0 : Result Git::update_submodules(const std::string &path, bool recursive)
421 : {
422 0 : if (path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
423 0 : std::vector<std::string> args = {"submodule", "update", "--init"};
424 0 : if (recursive) args.push_back("--recursive");
425 0 : return run_git(args, path);
426 0 : }
427 :
428 0 : Result Git::init_bare(const std::string &folder_path)
429 : {
430 0 : if (folder_path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
431 0 : auto result = run_git({"init", "--bare", folder_path});
432 0 : if (result.error()) return ESYSREPO_RESULT(result);
433 0 : return open(folder_path);
434 0 : }
435 :
436 0 : Result Git::init(const std::string &folder_path)
437 : {
438 0 : if (folder_path.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty path");
439 0 : auto result = run_git({"init", folder_path});
440 0 : if (result.error()) return ESYSREPO_RESULT(result);
441 0 : return open(folder_path);
442 0 : }
443 :
444 1 : Result Git::get_remotes(std::vector<git::Remote> &remotes)
445 : {
446 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
447 1 : remotes.clear();
448 :
449 1 : std::string out;
450 4 : auto result = run_git_out({"remote", "-v"}, m_folder, out);
451 1 : if (result.error()) return ESYSREPO_RESULT(result);
452 :
453 1 : std::map<std::string, std::string> name_to_url;
454 3 : for (const auto &line : split_lines(out))
455 : {
456 : // name <tab/sp> url <sp>(fetch|push)
457 2 : const auto first_sp = line.find_first_of(" \t");
458 2 : if (first_sp == std::string::npos) continue;
459 2 : const std::string name = line.substr(0, first_sp);
460 2 : std::string rest = trim(line.substr(first_sp + 1));
461 2 : const bool is_fetch = rest.size() >= 8 && rest.compare(rest.size() - 8, 8, " (fetch)") == 0;
462 2 : const bool is_push = rest.size() >= 7 && rest.compare(rest.size() - 7, 7, " (push)") == 0;
463 2 : if (is_fetch)
464 1 : rest = trim(rest.substr(0, rest.size() - 8));
465 1 : else if (is_push)
466 1 : rest = trim(rest.substr(0, rest.size() - 7));
467 : else
468 0 : continue;
469 :
470 3 : if (is_fetch || name_to_url.find(name) == name_to_url.end()) name_to_url[name] = rest;
471 3 : }
472 :
473 2 : for (const auto &kv : name_to_url)
474 : {
475 1 : git::Remote remote;
476 1 : remote.set_name(kv.first);
477 1 : remote.set_url(kv.second);
478 1 : remotes.push_back(remote);
479 1 : }
480 1 : return ESYSREPO_RESULT(ResultCode::OK);
481 2 : }
482 :
483 1 : Result Git::get_head_branch_remote(git::Branch &branch, git::Remote &remote)
484 : {
485 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
486 :
487 1 : std::string ref_name;
488 5 : auto result = run_git_out({"symbolic-ref", "-q", "HEAD"}, m_folder, ref_name);
489 1 : const bool detached = result.error();
490 1 : ref_name = trim(ref_name);
491 :
492 1 : std::string short_name;
493 4 : result = run_git_out({"rev-parse", "--abbrev-ref", "HEAD"}, m_folder, short_name);
494 1 : if (result.error()) return ESYSREPO_RESULT(result);
495 1 : short_name = trim(short_name);
496 :
497 1 : branch.set_is_head(true);
498 1 : branch.set_type(git::BranchType::LOCAL);
499 1 : branch.set_detached(detached);
500 1 : branch.set_name(short_name == "HEAD" ? std::string() : short_name);
501 1 : if (!detached && !ref_name.empty())
502 1 : branch.set_ref_name(ref_name);
503 0 : else if (!branch.get_name().empty())
504 0 : branch.set_ref_name("refs/heads/" + branch.get_name());
505 :
506 1 : if (detached || branch.get_name().empty())
507 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "HEAD is detached or has no branch");
508 :
509 1 : std::string remote_name;
510 1 : std::string merge_ref;
511 1 : std::string cfg_out;
512 4 : if (run_git_out({"config", "--get", "branch." + branch.get_name() + ".remote"}, m_folder, cfg_out).ok())
513 1 : remote_name = trim(cfg_out);
514 4 : if (run_git_out({"config", "--get", "branch." + branch.get_name() + ".merge"}, m_folder, cfg_out).ok())
515 1 : merge_ref = trim(cfg_out);
516 :
517 1 : if (remote_name.empty() || merge_ref.empty())
518 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "branch has no upstream remote");
519 :
520 1 : branch.set_remote_name(remote_name);
521 1 : remote.set_name(remote_name);
522 :
523 1 : std::string remote_url;
524 4 : result = run_git_out({"remote", "get-url", remote_name}, m_folder, remote_url);
525 1 : if (result.error()) return ESYSREPO_RESULT(result);
526 2 : remote.set_url(trim(remote_url));
527 :
528 1 : const std::string merge_heads = "refs/heads/";
529 1 : std::string short_merge = merge_ref;
530 1 : if (merge_ref.compare(0, merge_heads.size(), merge_heads) == 0)
531 1 : short_merge = merge_ref.substr(merge_heads.size());
532 :
533 2 : branch.set_remote_branch("refs/remotes/" + remote_name + "/" + short_merge);
534 2 : branch.set_remote_branch_name(remote_name + "/" + short_merge);
535 1 : return ESYSREPO_RESULT(ResultCode::OK);
536 5 : }
537 :
538 0 : Result Git::add_remote(const std::string &name, const std::string &url)
539 : {
540 0 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
541 0 : if (name.empty() || url.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty name or url");
542 0 : return run_git({"remote", "add", name, url}, m_folder);
543 : }
544 :
545 4 : Result Git::get_branches(git::Branches &branches, git::BranchType branch_type)
546 : {
547 4 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
548 4 : branches.clear();
549 :
550 4 : if (branch_type == git::BranchType::LOCAL || branch_type == git::BranchType::ALL)
551 : {
552 4 : std::string head_name;
553 20 : auto result = run_git_out({"rev-parse", "--abbrev-ref", "HEAD"}, m_folder, head_name);
554 4 : if (result.error()) return ESYSREPO_RESULT(result);
555 4 : head_name = trim(head_name);
556 4 : if (head_name == "HEAD") head_name.clear(); // detached
557 :
558 4 : std::string out;
559 12 : result = run_git_out({"show-ref", "--heads"}, m_folder, out);
560 : // Empty repo / no heads: not an error for listing.
561 4 : if (result.ok())
562 : {
563 8 : for (const auto &line : split_lines(out))
564 : {
565 : // <hash> <sp> refs/heads/<name>
566 4 : const auto sp = line.find(' ');
567 4 : if (sp == std::string::npos) continue;
568 4 : const std::string ref = trim(line.substr(sp + 1));
569 4 : const std::string heads = "refs/heads/";
570 4 : if (ref.compare(0, heads.size(), heads) != 0) continue;
571 4 : const std::string name = ref.substr(heads.size());
572 :
573 4 : auto branch = std::make_shared<git::Branch>();
574 4 : branch->set_ref_name(ref);
575 4 : branch->set_name(name);
576 4 : branch->set_type(git::BranchType::LOCAL);
577 4 : branch->set_is_head(!head_name.empty() && name == head_name);
578 :
579 4 : std::string remote;
580 4 : std::string merge;
581 4 : std::string cfg_out;
582 16 : if (run_git_out({"config", "--get", "branch." + name + ".remote"}, m_folder, cfg_out).ok())
583 4 : remote = trim(cfg_out);
584 16 : if (run_git_out({"config", "--get", "branch." + name + ".merge"}, m_folder, cfg_out).ok())
585 4 : merge = trim(cfg_out);
586 4 : if (!remote.empty() && !merge.empty())
587 : {
588 4 : branch->set_remote_name(remote);
589 : // Prefer refs/remotes/<remote>/<branch> for merge_analysis tips.
590 4 : const std::string merge_heads = "refs/heads/";
591 4 : std::string short_merge = merge;
592 4 : if (merge.compare(0, merge_heads.size(), merge_heads) == 0)
593 4 : short_merge = merge.substr(merge_heads.size());
594 8 : branch->set_remote_branch("refs/remotes/" + remote + "/" + short_merge);
595 4 : }
596 12 : branches.add(branch);
597 12 : }
598 : }
599 4 : }
600 :
601 4 : if (branch_type == git::BranchType::REMOTE || branch_type == git::BranchType::ALL)
602 : {
603 0 : std::string out;
604 0 : auto result = run_git_out({"show-ref"}, m_folder, out);
605 0 : if (result.ok())
606 : {
607 0 : for (const auto &line : split_lines(out))
608 : {
609 0 : const auto sp = line.find(' ');
610 0 : if (sp == std::string::npos) continue;
611 0 : const std::string ref = trim(line.substr(sp + 1));
612 0 : const std::string remotes = "refs/remotes/";
613 0 : if (ref.compare(0, remotes.size(), remotes) != 0) continue;
614 0 : if (ref.size() >= 5 && ref.compare(ref.size() - 5, 5, "/HEAD") == 0) continue;
615 :
616 0 : auto branch = std::make_shared<git::Branch>();
617 0 : branch->set_ref_name(ref);
618 0 : branch->set_name(ref.substr(remotes.size()));
619 0 : branch->set_type(git::BranchType::REMOTE);
620 0 : branches.add(branch);
621 0 : }
622 : }
623 0 : }
624 :
625 4 : return ESYSREPO_RESULT(ResultCode::OK);
626 : }
627 :
628 1 : Result Git::checkout(const std::string &branch, bool force)
629 : {
630 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
631 1 : if (branch.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty branch");
632 :
633 2 : std::vector<std::string> args = {"checkout"};
634 1 : if (force) args.emplace_back("-f");
635 2 : args.push_back(strip_rev_prefix(branch));
636 1 : return run_git(args, m_folder);
637 1 : }
638 :
639 1 : Result Git::reset(const git::CommitHash &commit, git::ResetType type)
640 : {
641 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
642 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
643 :
644 1 : const char *mode = nullptr;
645 1 : switch (type)
646 : {
647 0 : case git::ResetType::NOT_SET: return ESYSREPO_RESULT(ResultCode::GIT_RESET_TYPE_NOT_SET);
648 : case git::ResetType::SOFT: mode = "--soft"; break;
649 0 : case git::ResetType::MIXED: mode = "--mixed"; break;
650 0 : case git::ResetType::HARD: mode = "--hard"; break;
651 0 : default: return ESYSREPO_RESULT(ResultCode::GIT_RESET_TYPE_UNKNOWN);
652 : }
653 4 : return run_git({"reset", mode, commit.get_hash()}, m_folder);
654 : }
655 :
656 1 : Result Git::fastforward(const git::CommitHash &commit)
657 : {
658 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
659 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
660 : // Matches libgit2 path: move HEAD + worktree to the tip (FF only).
661 4 : return run_git({"merge", "--ff-only", commit.get_hash()}, m_folder);
662 : }
663 :
664 3 : Result Git::get_last_commit(git::CommitHash &commit)
665 : {
666 3 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
667 3 : std::string out;
668 12 : auto result = run_git_out({"rev-parse", "HEAD"}, m_folder, out);
669 3 : if (result.error()) return ESYSREPO_RESULT(result);
670 6 : commit.set_hash(trim(out));
671 3 : return ESYSREPO_RESULT(ResultCode::OK);
672 6 : }
673 :
674 1 : Result Git::get_last_commit(git::Commit &commit, bool /*get_all_notes*/)
675 : {
676 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
677 :
678 1 : commit.clear();
679 : // Notes stay empty on gitcmdline (use hybrid/libgit2 for note-heavy cmds).
680 : // Parse cat-file (not --format=%…): shell_quote doubles '%' for cmd.exe, which
681 : // breaks git pretty placeholders (%% becomes a literal '%' in the format).
682 1 : git::CommitHash hash;
683 1 : auto result = get_last_commit(hash);
684 1 : if (result.error()) return ESYSREPO_RESULT(result);
685 1 : commit.set_hash(hash.get_hash());
686 :
687 1 : std::string out;
688 4 : result = run_git_out({"cat-file", "-p", "HEAD"}, m_folder, out);
689 1 : if (result.error()) return ESYSREPO_RESULT(result);
690 :
691 3 : auto parse_ident = [](const std::string &line, git::Signature &sig) {
692 : // author Name <email> unix_ts tz
693 2 : const auto lt = line.find('<');
694 2 : const auto gt = line.find('>');
695 2 : if (lt == std::string::npos || gt == std::string::npos || gt <= lt) return;
696 4 : sig.set_name(trim(line.substr(0, lt)));
697 2 : sig.set_email(line.substr(lt + 1, gt - lt - 1));
698 2 : const std::string rest = trim(line.substr(gt + 1));
699 2 : const auto sp = rest.find(' ');
700 2 : try
701 : {
702 4 : const auto epoch = static_cast<std::time_t>(std::stoll(sp == std::string::npos ? rest : rest.substr(0, sp)));
703 2 : sig.set_date_time(std::chrono::system_clock::from_time_t(epoch));
704 : }
705 0 : catch (...)
706 : {
707 0 : }
708 2 : };
709 :
710 1 : std::string message;
711 1 : bool in_message = false;
712 7 : for (const auto &line : split_lines(out))
713 : {
714 6 : if (in_message)
715 : {
716 1 : if (!message.empty()) message.push_back('\n');
717 1 : message += line;
718 1 : continue;
719 : }
720 5 : if (line.empty())
721 : {
722 1 : in_message = true;
723 1 : continue;
724 : }
725 4 : if (line.rfind("author ", 0) == 0)
726 2 : parse_ident(line.substr(7), commit.get_author_sign());
727 3 : else if (line.rfind("committer ", 0) == 0)
728 2 : parse_ident(line.substr(10), commit.get_committer_sign());
729 1 : }
730 :
731 1 : while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) message.pop_back();
732 1 : commit.set_message(message);
733 1 : const auto nl = message.find('\n');
734 1 : if (nl == std::string::npos)
735 : {
736 1 : commit.set_summary(message);
737 2 : commit.set_body({});
738 : }
739 : else
740 : {
741 0 : commit.set_summary(message.substr(0, nl));
742 0 : std::string body = message.substr(nl + 1);
743 0 : while (!body.empty() && (body.front() == '\n' || body.front() == '\r')) body.erase(body.begin());
744 0 : commit.set_body(body);
745 0 : }
746 :
747 1 : return ESYSREPO_RESULT(ResultCode::OK);
748 2 : }
749 :
750 1 : Result Git::get_parent_commit(const git::CommitHash &commit, git::CommitHash &parent, int nth_parent)
751 : {
752 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
753 1 : if (commit.get_hash().empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty commit");
754 :
755 1 : if (nth_parent == 0)
756 : {
757 0 : parent = commit;
758 0 : return ESYSREPO_RESULT(ResultCode::OK);
759 : }
760 1 : if (nth_parent < 0) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "invalid nth_parent");
761 :
762 : // First-parent walk (matches libgit2 get_parent_commit).
763 1 : std::string out;
764 1 : auto result =
765 4 : run_git_out({"rev-parse", commit.get_hash() + "~" + std::to_string(nth_parent)}, m_folder, out);
766 1 : if (result.error()) return ESYSREPO_RESULT(result);
767 2 : parent.set_hash(trim(out));
768 1 : return ESYSREPO_RESULT(ResultCode::OK);
769 2 : }
770 :
771 1 : Result Git::is_dirty(bool &dirty)
772 : {
773 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
774 1 : dirty = false;
775 1 : std::string out;
776 4 : auto result = run_git_out({"status", "--porcelain"}, m_folder, out);
777 1 : if (result.error()) return ESYSREPO_RESULT(result);
778 1 : dirty = !trim(out).empty();
779 1 : return ESYSREPO_RESULT(ResultCode::OK);
780 2 : }
781 :
782 1 : Result Git::is_detached(bool &detached)
783 : {
784 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
785 1 : detached = false;
786 : // symbolic-ref fails (nonzero) when HEAD is detached.
787 5 : auto result = run_git({"symbolic-ref", "-q", "HEAD"}, m_folder);
788 1 : detached = result.error();
789 1 : return ESYSREPO_RESULT(ResultCode::OK);
790 1 : }
791 :
792 1 : Result Git::get_status(git::RepoStatus &repo_status)
793 : {
794 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
795 :
796 1 : std::string out;
797 5 : auto result = run_git_out({"status", "--porcelain=v1", "-uall"}, m_folder, out);
798 1 : if (result.error()) return ESYSREPO_RESULT(result);
799 :
800 3 : for (const auto &line : split_lines(out))
801 : {
802 2 : if (line.size() < 2) continue;
803 2 : const char x = line[0];
804 2 : const char y = line[1];
805 2 : std::string old_path;
806 2 : std::string new_path;
807 2 : if (!parse_porcelain_paths(line.substr(2), old_path, new_path)) continue;
808 :
809 2 : if (x == '?' && y == '?')
810 : {
811 1 : add_porcelain_status(repo_status, git::StatusType::WORKING_DIR, git::StatusSubType::NEW,
812 : git::DiffDeltaType::UNTRACKED, old_path, new_path);
813 1 : continue;
814 : }
815 1 : if (x == '!' && y == '!')
816 : {
817 0 : add_porcelain_status(repo_status, git::StatusType::IGNORED, git::StatusSubType::NOT_SET,
818 : git::DiffDeltaType::IGNORED, old_path, new_path);
819 0 : continue;
820 : }
821 1 : if (x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D'))
822 : {
823 0 : add_porcelain_status(repo_status, git::StatusType::CONFLICTED, git::StatusSubType::NOT_SET,
824 : git::DiffDeltaType::CONFLICTED, old_path, new_path);
825 0 : continue;
826 : }
827 :
828 1 : if (x != ' ' && x != '?')
829 : {
830 0 : add_porcelain_status(repo_status, git::StatusType::INDEX, porcelain_to_subtype(x),
831 : porcelain_to_delta_type(x, false), old_path, new_path);
832 : }
833 1 : if (y != ' ' && y != '?')
834 : {
835 2 : add_porcelain_status(repo_status, git::StatusType::WORKING_DIR, porcelain_to_subtype(y),
836 : porcelain_to_delta_type(y, true), old_path, new_path);
837 : }
838 4 : }
839 :
840 1 : return ESYSREPO_RESULT(ResultCode::OK);
841 2 : }
842 :
843 0 : Result_t<bool> Git::is_ssh_agent_running(bool log_once)
844 : {
845 0 : detect_ssh_agent(log_once);
846 0 : return ESYSREPO_RESULT_T(ResultCode::OK, s_ssh_agent_running);
847 : }
848 :
849 0 : void Git::detect_ssh_agent(bool /*log_once*/)
850 : {
851 0 : if (s_detect_ssh_agent_done) return;
852 0 : s_detect_ssh_agent_done = true;
853 : #ifdef _WIN32
854 : // Pageant / Windows OpenSSH agent: presence of SSH_AUTH_SOCK or ssh-agent service is enough for CLI git.
855 : const char *sock = std::getenv("SSH_AUTH_SOCK");
856 : s_ssh_agent_running = (sock != nullptr && sock[0] != '\0') || (std::getenv("SSH_AGENT_PID") != nullptr);
857 : #else
858 0 : const char *sock = std::getenv("SSH_AUTH_SOCK");
859 0 : s_ssh_agent_running = (sock != nullptr && sock[0] != '\0');
860 : #endif
861 : }
862 :
863 2 : Result Git::merge_analysis(const std::vector<std::string> &refs, git::MergeAnalysisResult &merge_analysis_result,
864 : std::vector<git::CommitHash> &commits)
865 : {
866 2 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
867 2 : merge_analysis_result = git::MergeAnalysisResult::NOT_SET;
868 2 : commits.clear();
869 2 : if (refs.empty()) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty refs");
870 :
871 2 : std::string head;
872 8 : auto result = run_git_out({"rev-parse", "HEAD"}, m_folder, head);
873 2 : if (result.error()) return ESYSREPO_RESULT(result);
874 2 : head = trim(head);
875 :
876 : // Sync only uses the first upstream ref today; analyze that tip.
877 2 : const std::string &ref = refs.front();
878 2 : std::string target;
879 6 : result = run_git_out({"rev-parse", ref}, m_folder, target);
880 2 : if (result.error()) return ESYSREPO_RESULT(result);
881 2 : target = trim(target);
882 :
883 2 : git::CommitHash target_commit;
884 2 : target_commit.set_hash(target);
885 2 : commits.push_back(target_commit);
886 :
887 2 : if (head == target)
888 : {
889 1 : merge_analysis_result = git::MergeAnalysisResult::UP_TO_DATE;
890 1 : return ESYSREPO_RESULT(ResultCode::OK);
891 : }
892 :
893 1 : std::string base;
894 4 : result = run_git_out({"merge-base", "HEAD", ref}, m_folder, base);
895 1 : if (result.error()) return ESYSREPO_RESULT(result);
896 1 : base = trim(base);
897 :
898 1 : if (base == head)
899 1 : merge_analysis_result = git::MergeAnalysisResult::FASTFORWARD;
900 0 : else if (base == target)
901 0 : merge_analysis_result = git::MergeAnalysisResult::UP_TO_DATE;
902 : else
903 0 : merge_analysis_result = git::MergeAnalysisResult::NORMAL;
904 :
905 1 : return ESYSREPO_RESULT(ResultCode::OK);
906 6 : }
907 :
908 0 : Result Git::fetch_all_notes(const std::string &)
909 : {
910 0 : return not_implemented();
911 : }
912 :
913 1 : Result_t<bool> Git::has_branch(const std::string &name, git::BranchType branch_type)
914 : {
915 1 : if (!m_open) return ESYSREPO_RESULT_T(ResultCode::GIT_GENERIC_ERROR, false);
916 1 : git::Branches branches;
917 1 : auto result = get_branches(branches, branch_type);
918 1 : if (result.error()) return ESYSREPO_RESULT_T(result, false);
919 1 : const std::string tip = strip_rev_prefix(name);
920 1 : for (const auto &b : branches.get())
921 : {
922 1 : if (b->get_name() == tip || b->get_name() == name || b->get_ref_name() == name)
923 1 : return ESYSREPO_RESULT_T(ResultCode::OK, true);
924 : }
925 0 : return ESYSREPO_RESULT_T(ResultCode::OK, false);
926 1 : }
927 :
928 0 : Result Git::get_hash(const std::string &revision, std::string &hash, git::BranchType /*branch_type*/)
929 : {
930 0 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
931 0 : hash.clear();
932 0 : std::string out;
933 0 : auto result = run_git_out({"rev-parse", revision}, m_folder, out);
934 0 : if (result.error()) return ESYSREPO_RESULT(result);
935 0 : hash = trim(out);
936 0 : return ESYSREPO_RESULT(ResultCode::OK);
937 0 : }
938 :
939 0 : Result Git::walk_commits(std::shared_ptr<git::WalkCommit>)
940 : {
941 0 : return not_implemented();
942 : }
943 :
944 0 : Result Git::diff(const git::CommitHash &, std::shared_ptr<git::Diff>)
945 : {
946 0 : return not_implemented();
947 : }
948 :
949 1 : Result Git::get_ahead_behind(git::AheadBehind &ahead_behind, const git::Branch &local_branch)
950 : {
951 1 : ahead_behind.set_ahead(0);
952 1 : ahead_behind.set_behind(0);
953 1 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
954 1 : if (local_branch.get_name().empty() || local_branch.get_remote_branch().empty())
955 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "branch missing name or upstream");
956 :
957 1 : return get_ahead_behind(ahead_behind, local_branch.get_name(), local_branch.get_remote_branch());
958 : }
959 :
960 2 : Result Git::get_ahead_behind(git::AheadBehind &ahead_behind, const std::string &first_ref,
961 : const std::string &second_ref)
962 : {
963 2 : ahead_behind.set_ahead(0);
964 2 : ahead_behind.set_behind(0);
965 2 : if (!m_open) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "repository not open");
966 2 : if (first_ref.empty() || second_ref.empty())
967 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "empty ref");
968 :
969 2 : std::string out;
970 12 : auto result = run_git_out({"rev-list", "--left-right", "--count", first_ref + "..." + second_ref}, m_folder, out);
971 2 : if (result.error()) return ESYSREPO_RESULT(result);
972 :
973 2 : out = trim(out);
974 2 : const auto tab = out.find('\t');
975 2 : if (tab == std::string::npos) return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "unexpected rev-list output");
976 :
977 2 : try
978 : {
979 4 : ahead_behind.set_ahead(static_cast<std::size_t>(std::stoull(out.substr(0, tab))));
980 4 : ahead_behind.set_behind(static_cast<std::size_t>(std::stoull(out.substr(tab + 1))));
981 : }
982 0 : catch (...)
983 : {
984 0 : return ESYSREPO_RESULT(ResultCode::GIT_GENERIC_ERROR, "failed to parse ahead/behind counts");
985 0 : }
986 2 : return ESYSREPO_RESULT(ResultCode::OK);
987 4 : }
988 :
989 0 : Result Git::add_note(git::NoteId &, const std::string &, const std::string &, bool)
990 : {
991 0 : return not_implemented();
992 : }
993 :
994 0 : Result Git::remove_note(const git::NoteId &)
995 : {
996 0 : return not_implemented();
997 : }
998 :
999 0 : Result Git::remove_note(const git::CommitHash &, const std::string &)
1000 : {
1001 0 : return not_implemented();
1002 : }
1003 :
1004 0 : Result Git::remove_note_last_commit(const std::string &)
1005 : {
1006 0 : return not_implemented();
1007 : }
1008 :
1009 0 : Result Git::push(const std::string &, const std::string &, const std::string &)
1010 : {
1011 0 : return not_implemented();
1012 : }
1013 :
1014 0 : Result Git::push_notes(const std::string &, const std::string &)
1015 : {
1016 0 : return not_implemented();
1017 : }
1018 :
1019 0 : const std::string &Git::get_version()
1020 : {
1021 0 : return s_get_version();
1022 : }
1023 :
1024 0 : const std::string &Git::get_lib_name()
1025 : {
1026 0 : return s_get_lib_name();
1027 : }
1028 :
1029 0 : const std::string &Git::s_get_version()
1030 : {
1031 0 : static const std::string version = "git-cmdline";
1032 0 : return version;
1033 : }
1034 :
1035 0 : const std::string &Git::s_get_lib_name()
1036 : {
1037 0 : static const std::string name = "gitcmdline";
1038 0 : return name;
1039 : }
1040 :
1041 0 : bool Git::do_is_ssh_backend_supported(git::SshBackend backend) const
1042 : {
1043 0 : return backend == git::SshBackend::Exec;
1044 : }
1045 :
1046 0 : bool Git::do_is_ssh_backend_available(git::SshBackend backend, bool /*force*/)
1047 : {
1048 0 : if (backend != git::SshBackend::Exec) return false;
1049 0 : return !find_git_executable().empty();
1050 : }
1051 :
1052 0 : Result Git::do_set_ssh_backend(git::SshBackend backend)
1053 : {
1054 0 : if (backend != git::SshBackend::Exec)
1055 0 : return ESYSREPO_RESULT(ResultCode::GIT_SSH_BACKEND_NOT_SUPPORTED);
1056 0 : m_ssh_backend = backend;
1057 0 : return ESYSREPO_RESULT(ResultCode::OK);
1058 : }
1059 :
1060 0 : Result_t<git::SshBackend> Git::do_get_ssh_backend() const
1061 : {
1062 0 : return ESYSREPO_RESULT_T(ResultCode::OK, m_ssh_backend);
1063 : }
1064 :
1065 : } // namespace esys::repo::gitcmdline
|