33#include "foundation/compat_fs.h"
44#include "foundation/constants.h"
55#include "foundation/log.h"
6+ #include "foundation/mem_core.h"
67#include "foundation/platform.h"
78#include "foundation/str_util.h"
89
@@ -17,6 +18,17 @@ enum {
1718 GIT_OUTPUT_MAX = 4096 ,
1819};
1920
21+ /* Bounded worktree-status stream constants.
22+ * STATUS_FIELD_MAX is the largest single NUL-delimited record field we
23+ * buffer; a field at/over the bound is treated as a malformed stream and the
24+ * whole snapshot fails closed. PATH components of real repos are far below
25+ * this even with long paths. */
26+ enum {
27+ STATUS_HEADER_LEN = 3 , /* porcelain-v1 record prefix "XY " */
28+ STATUS_CHUNK_MAX = CBM_SZ_4K ,
29+ STATUS_FIELD_MAX = CBM_SZ_16K ,
30+ };
31+
2032static char * git_strdup (const char * s ) {
2133 if (!s ) {
2234 s = "" ;
@@ -641,3 +653,210 @@ int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_
641653 }
642654 return off ;
643655}
656+
657+ /* ── Bounded worktree status ──────────────────────────────────────────────
658+ * git status --porcelain=v1 -z --untracked-files=all is parsed record by
659+ * record, NUL-delimited, over a bounded chunk buffer. The full stream is
660+ * counted but only max_samples paths per class (tracked / untracked) are
661+ * ever retained, so a huge dirty tree cannot grow memory. Rename/copy
662+ * records carry a second NUL-terminated path (the source); it is consumed
663+ * without being counted so one rename counts once. */
664+
665+ typedef struct {
666+ cbm_worktree_status_t * out ;
667+ int max_samples ;
668+ bool expecting_source ; /* next field is the source path of an R/C record */
669+ bool parse_ok ;
670+ char partial [STATUS_FIELD_MAX ];
671+ size_t partial_len ;
672+ } status_parser_t ;
673+
674+ /* porcelain-v1 status columns (X/Y): documented codes plus the lowercase
675+ * submodule variants and type-change 'T'. Anything else is not a record
676+ * header, so a malformed stream fails closed instead of being misread. */
677+ static bool status_code_ok (char c ) {
678+ switch (c ) {
679+ case ' ' :
680+ case 'M' :
681+ case 'A' :
682+ case 'D' :
683+ case 'R' :
684+ case 'C' :
685+ case 'U' :
686+ case '?' :
687+ case '!' :
688+ case 'T' :
689+ case 'm' :
690+ case 'a' :
691+ case 'd' :
692+ case 'r' :
693+ case 'c' :
694+ case 'u' :
695+ case 't' :
696+ return true;
697+ default :
698+ return false;
699+ }
700+ }
701+
702+ /* Retain one bounded sample; the running total always advances so the caller
703+ * sees the true stream count even when samples are capped or OOM. */
704+ static void status_add_sample (int * total , char * * * samples , int * sample_count , bool * truncated ,
705+ int max_samples , const char * path , size_t path_len ) {
706+ (* total )++ ;
707+ if (* sample_count >= max_samples ) {
708+ * truncated = true;
709+ return ;
710+ }
711+ char * copy = (char * )cbm_alloc (CBM_MEM_CLASS_OTHER , path_len + 1 );
712+ if (!copy ) {
713+ * truncated = true;
714+ return ;
715+ }
716+ memcpy (copy , path , path_len );
717+ copy [path_len ] = '\0' ;
718+ char * * grown = (char * * )cbm_realloc (CBM_MEM_CLASS_OTHER , * samples ,
719+ (size_t )(* sample_count + 1 ) * sizeof (char * ));
720+ if (!grown ) {
721+ cbm_free (CBM_MEM_CLASS_OTHER , copy );
722+ * truncated = true;
723+ return ;
724+ }
725+ * samples = grown ;
726+ (* samples )[* sample_count ] = copy ;
727+ (* sample_count )++ ;
728+ }
729+
730+ /* Parse one complete NUL-delimited field. Returns false on malformed input,
731+ * which fails the whole snapshot closed. */
732+ static bool status_parse_record (cbm_worktree_status_t * out , status_parser_t * parser ,
733+ const char * field , size_t field_len ) {
734+ if (parser -> expecting_source ) {
735+ parser -> expecting_source = false;
736+ return true; /* second path of a rename/copy record — not counted */
737+ }
738+ if (field_len < STATUS_HEADER_LEN || field [2 ] != ' ' || !status_code_ok (field [0 ]) ||
739+ !status_code_ok (field [1 ])) {
740+ return false;
741+ }
742+ const char * path = field + STATUS_HEADER_LEN ;
743+ size_t path_len = field_len - STATUS_HEADER_LEN ;
744+ if (field [0 ] == '?' && field [1 ] == '?' ) {
745+ status_add_sample (& out -> untracked_count , & out -> untracked_paths ,
746+ & out -> untracked_sample_count , & out -> untracked_truncated ,
747+ parser -> max_samples , path , path_len );
748+ return true;
749+ }
750+ if (field [0 ] == 'R' || field [0 ] == 'C' || field [1 ] == 'R' || field [1 ] == 'C' ) {
751+ parser -> expecting_source = true;
752+ }
753+ status_add_sample (& out -> tracked_count , & out -> tracked_paths , & out -> tracked_sample_count ,
754+ & out -> tracked_truncated , parser -> max_samples , path , path_len );
755+ return true;
756+ }
757+
758+ /* Feed a chunk of the NUL-delimited stream through the bounded record parser. */
759+ static void status_feed (status_parser_t * parser , const char * data , size_t data_len ) {
760+ size_t i = 0 ;
761+ while (i < data_len ) {
762+ if (parser -> partial_len >= STATUS_FIELD_MAX ) {
763+ parser -> parse_ok = false; /* field exceeded the bound: malformed */
764+ return ;
765+ }
766+ size_t j = i ;
767+ while (j < data_len && data [j ] != '\0' &&
768+ parser -> partial_len + (j - i ) < STATUS_FIELD_MAX ) {
769+ j ++ ;
770+ }
771+ size_t take = j - i ;
772+ memcpy (parser -> partial + parser -> partial_len , data + i , take );
773+ parser -> partial_len += take ;
774+ i += take ;
775+ if (i < data_len && data [i ] == '\0' ) {
776+ if (!status_parse_record (parser -> out , parser , parser -> partial , parser -> partial_len )) {
777+ parser -> parse_ok = false;
778+ return ;
779+ }
780+ parser -> partial_len = 0 ;
781+ i ++ ; /* consume the record terminator */
782+ } else if (i >= data_len ) {
783+ return ; /* chunk exhausted mid-record */
784+ }
785+ }
786+ }
787+
788+ int cbm_git_worktree_status (const char * validated_root , int max_samples ,
789+ cbm_worktree_status_t * out ) {
790+ if (!out ) {
791+ return CBM_NOT_FOUND ;
792+ }
793+ memset (out , 0 , sizeof (* out ));
794+ if (!validated_root || !validated_root [0 ] || max_samples < 0 ) {
795+ return CBM_NOT_FOUND ;
796+ }
797+ if (!git_validate_repo_path (validated_root )) {
798+ return CBM_NOT_FOUND ;
799+ }
800+
801+ char cmd [GIT_CMD_MAX ];
802+ #ifdef _WIN32
803+ const char * null_dev = "NUL" ;
804+ #else
805+ const char * null_dev = "/dev/null" ;
806+ #endif
807+ int n = snprintf (cmd , sizeof (cmd ),
808+ "git -C \"%s\" status --porcelain=v1 -z --untracked-files=all 2>%s" ,
809+ validated_root , null_dev );
810+ if (n < 0 || n >= (int )sizeof (cmd )) {
811+ return CBM_NOT_FOUND ;
812+ }
813+
814+ /* An inherited GIT_DIR/GIT_WORK_TREE (a git hook, an editor) overrides
815+ * `git -C`, which would report another repository's worktree here. */
816+ FILE * fp = cbm_popen_git (cmd );
817+ if (!fp ) {
818+ return 0 ; /* out is zeroed: available=false */
819+ }
820+
821+ status_parser_t parser ;
822+ memset (& parser , 0 , sizeof (parser ));
823+ parser .out = out ;
824+ parser .max_samples = max_samples ;
825+ parser .parse_ok = true;
826+
827+ char chunk [STATUS_CHUNK_MAX ];
828+ size_t got ;
829+ bool io_error = false;
830+ while (parser .parse_ok && (got = fread (chunk , 1 , sizeof (chunk ), fp )) > 0 ) {
831+ status_feed (& parser , chunk , got );
832+ }
833+ if (ferror (fp )) {
834+ io_error = true;
835+ }
836+ int rc = cbm_pclose (fp );
837+
838+ if (rc != 0 || !parser .parse_ok || io_error || parser .partial_len > 0 ) {
839+ /* Fail closed: any deviation from a clean, complete status stream is a
840+ * machine-readable "unavailable", never a clean worktree. */
841+ cbm_git_worktree_status_free (out );
842+ return 0 ;
843+ }
844+
845+ out -> available = true;
846+ return 0 ;
847+ }
848+
849+ void cbm_git_worktree_status_free (cbm_worktree_status_t * st ) {
850+ if (!st ) {
851+ return ;
852+ }
853+ for (int i = 0 ; i < st -> tracked_sample_count ; i ++ ) {
854+ cbm_free (CBM_MEM_CLASS_OTHER , st -> tracked_paths [i ]);
855+ }
856+ cbm_free (CBM_MEM_CLASS_OTHER , st -> tracked_paths );
857+ for (int i = 0 ; i < st -> untracked_sample_count ; i ++ ) {
858+ cbm_free (CBM_MEM_CLASS_OTHER , st -> untracked_paths [i ]);
859+ }
860+ cbm_free (CBM_MEM_CLASS_OTHER , st -> untracked_paths );
861+ memset (st , 0 , sizeof (* st ));
862+ }
0 commit comments