package git import ( "context" "path/filepath" "strconv" "strings" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) // Status snapshots one repository at path. Bare repositories are reported // with StatusBare and no further detail, since they have no working tree. func (e *Executor) Status(ctx context.Context, path string) (status.RepoInfo, error) { info := status.RepoInfo{Path: path, Name: filepath.Base(path)} bare, err := e.Run(ctx, path, "rev-parse", "--is-bare-repository") if err != nil { return info, err } if bare == "true" { info.Status = status.StatusBare return info, nil } out, err := e.Run(ctx, path, "status", "--porcelain=v2", "--branch", "-z") if err != nil { return info, err } parsePorcelainV2(out, &info) info.RemoteURL = e.remoteURL(ctx, path) info.StashCount = e.stashCount(ctx, path) info.Status = classify(&info) return info, nil } // classify derives the coarse-grained status from the parsed snapshot. func classify(info *status.RepoInfo) status.RepoStatus { switch { case info.AheadBy > 0 && info.BehindBy > 0: return status.StatusDiverged case info.BehindBy > 0: return status.StatusBehind case info.AheadBy > 0: return status.StatusAhead case info.FileCount() > 0: return status.StatusModified case info.Detached: return status.StatusDetached default: return status.StatusClean } } // parsePorcelainV2 parses the NUL-separated output of // `git status --porcelain=v2 --branch -z` into info. Unknown tokens are // ignored so future git versions remain compatible. func parsePorcelainV2(out string, info *status.RepoInfo) { for _, token := range strings.Split(out, "\x00") { token = strings.TrimSpace(token) if token == "" { continue } switch { case strings.HasPrefix(token, "# branch.head "): head := strings.TrimPrefix(token, "# branch.head ") if head == "(detached)" { info.Detached = true info.Branch = "(detached)" } else { info.Branch = head } case strings.HasPrefix(token, "# branch.ab "): parseAheadBehind(token, info) case strings.HasPrefix(token, "1 "), strings.HasPrefix(token, "2 "): parseChangeRecord(token, info) case strings.HasPrefix(token, "?"): info.UntrackedFiles = append(info.UntrackedFiles, strings.TrimSpace(strings.TrimPrefix(token, "?"))) } } } // parseAheadBehind parses "# branch.ab +3 -5" into AheadBy/BehindBy. func parseAheadBehind(token string, info *status.RepoInfo) { fields := strings.Fields(token) // [#, branch.ab, +3, -5] if len(fields) < 4 { return } if n, err := strconv.Atoi(strings.TrimPrefix(fields[2], "+")); err == nil { info.AheadBy = n } if n, err := strconv.Atoi(strings.TrimPrefix(fields[3], "-")); err == nil { info.BehindBy = n } } // parseChangeRecord handles porcelain v2 change records: // // 1 // 2 // // X is the staged status and Y the unstaged one; '.' means unmodified. // With -z the path is raw, so only the fixed leading fields are split and // the remainder (which may contain spaces) is taken as the path. func parseChangeRecord(token string, info *status.RepoInfo) { fields := strings.Fields(token) if len(fields[1]) < 2 { return } var pathIdx int switch fields[0] { case "1": if len(fields) < 9 { return } pathIdx = 8 case "2": if len(fields) < 10 { return } pathIdx = 9 default: return } path := strings.Join(fields[pathIdx:], " ") if xy := fields[1]; xy[0] != '.' { info.StagedFiles = append(info.StagedFiles, path) } if xy := fields[1]; xy[1] != '.' { info.ModifiedFiles = append(info.ModifiedFiles, path) } } // remoteURL returns the fetch URL of the first configured remote, if any. func (e *Executor) remoteURL(ctx context.Context, path string) string { out, err := e.Run(ctx, path, "config", "--get-regexp", `^remote\..*\.url$`) if err != nil { return "" } for _, line := range strings.Split(out, "\n") { if f := strings.Fields(line); len(f) >= 2 { return f[1] } } return "" } // stashCount returns the number of stashes in the repository. func (e *Executor) stashCount(ctx context.Context, path string) int { out, err := e.Run(ctx, path, "stash", "list") if err != nil { return 0 } if strings.TrimSpace(out) == "" { return 0 } return strings.Count(strings.TrimSpace(out), "\n") + 1 }